commit 4011b2d8a6b293288e630d7e7e3fd2aca379c867 Author: Sprinterfreak Date: Tue Jul 23 00:27:15 2024 +0200 Start SDM series energy meters tools repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45b709d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.o +**.swp diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..83d227d --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +sdm2mqtt: sdm.o sdm2mqtt.o + cc -o sdm2mqtt sdm2mqtt.o sdm.o -lmosquitto -lmodbus + +sdm2mqtt.o: sdm2mqtt.c config.h + cc -c sdm2mqtt.c + +sdm.o: sdm.c sdm*_registers.h + cc -c sdm.c + +clean: + rm -rf *.o + +install: sdm2mqtt + envsubst /etc/systemd/system/sdm2mqtt.service + systemctl daemon-reload + systemctl enable sdm2mqtt.service + systemctl start sdm2mqtt.service diff --git a/README.md b/README.md new file mode 100644 index 0000000..416c356 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +## SDM2MQTT Bridge + +Reads SDM series energy meters modbus registers and pushes them to mqtt topic +as json object. + +#### Requirements + +```bash +apt install git make libmodbus-dev libmosquitto-dev +``` + +#### Configure + +Modify `config.h` to your needs + +#### Build + +```bash +make +``` + +#### Install + +```bash +make install +``` + +### Copyright + +2024 Waijb +Documentation by Sprinterfreak diff --git a/config.h b/config.h new file mode 100644 index 0000000..bbe4ed5 --- /dev/null +++ b/config.h @@ -0,0 +1,55 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#include "sdm.h" + +typedef struct { + int addr; + char *friendlyname; + enum sdm_types type; +} config_meters_t; + +static config_meters_t meters[] = { + { 11, + "Feld", + SDM630M + }, + { 12, + "Vorzelt", + SDM630M + }, + { 0, + NULL, + SDMNONE + } +}; + +typedef struct { + char *ser_device; + int ser_baud; + int ser_databits; + char ser_parity; + int ser_stopbits; + config_meters_t *meters; + const char *mosq_name; + const char *mosq_topicprefix; + const char *mosq_host; + const int mosq_port; + const int mosq_keepalive; +} config_t; + +static config_t cfg = { + "/dev/ttyUSB0", + 38400, + 8, + 'N', + 1, + meters, + "sdm2mqtt2", + "sdm2mqtt2", + "localhost", + 1883, + 5 +}; + +#endif diff --git a/grafana/dashboard.json b/grafana/dashboard.json new file mode 100644 index 0000000..e65dfd4 --- /dev/null +++ b/grafana/dashboard.json @@ -0,0 +1,1001 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 2, + "id": 3, + "links": [], + "liveNow": true, + "panels": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 3, + "displayName": "${__field.labels['phase']}", + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + }, + "unit": "watth" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 10, + "x": 0, + "y": 0 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "vertical", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "// v.windowPeriod is a variable referring to the current optimized window period (currently: $interval)\nbase = from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => (\n r[\"_measurement\"] == \"camp_power\" and exists r.phase and r[\"_field\"] == \"total\"))\n\nvfirst = base\n |> first()\n\nvlast = base\n |> last()\n\nunion(tables: [vfirst, vlast])\n |> difference()\n |> group(columns: [\"phase\"])\n |> sum()", + "refId": "A" + } + ], + "title": "Used Energy per Phase", + "transparent": true, + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 0 + }, + "id": 13, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "", + "mode": "html" + }, + "pluginVersion": "11.0.0", + "transparent": true, + "type": "text" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 3, + "displayName": "Sum", + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + }, + "unit": "watth" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 3, + "x": 18, + "y": 0 + }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "// v.windowPeriod is a variable referring to the current optimized window period (currently: $interval)\nbase = from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => (\n r[\"_measurement\"] == \"camp_power\" and exists r.phase and r[\"_field\"] == \"total\"))\n\nvfirst = base\n |> first()\n\nvlast = base\n |> last()\n\nunion(tables: [vfirst, vlast])\n |> difference()\n |> group(columns: [\"mac\"])\n |> sum()", + "refId": "A" + } + ], + "title": "Used Energy Total", + "transparent": true, + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "displayName": "Sum", + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-blue", + "value": null + } + ] + }, + "unit": "currencyEUR" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 3, + "x": 21, + "y": 0 + }, + "id": 12, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "// v.windowPeriod is a variable referring to the current optimized window period (currently: $interval)\nbase = from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => (\n r[\"_measurement\"] == \"camp_power\" and exists r.phase and r[\"_field\"] == \"total\"))\n\nvfirst = base\n |> first()\n\nvlast = base\n |> last()\n\nunion(tables: [vfirst, vlast])\n |> difference()\n |> group(columns: [\"mac\"])\n |> sum()\n |> map(fn: (r) => ({r with _value: r._value * ${energy_cost:float} / 1000.0}))", + "refId": "A" + } + ], + "title": "Energy Approximated Cost", + "transparent": true, + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "displayName": "${__field.labels['phase']}", + "fieldMinMax": false, + "mappings": [], + "max": 70, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 40 + }, + { + "color": "red", + "value": 45 + }, + { + "color": "dark-purple", + "value": 50 + } + ] + }, + "unit": "amp" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 2, + "options": { + "displayMode": "lcd", + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "top", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "text": {}, + "valueMode": "color" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop:v.timeRangeStop)\n |> filter(fn: (r) =>\n r._measurement == \"camp_power\" and\n r._field == \"current\"\n )\n |> aggregateWindow(every: v.windowPeriod, fn: last)\n |> group(columns: [\"phase\"])", + "refId": "A" + } + ], + "title": "Ampere per Phase", + "transparent": true, + "type": "bargauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "displayName": "${__field.labels['phase']}", + "mappings": [], + "max": 300, + "min": 150, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 190 + }, + { + "color": "green", + "value": 215 + }, + { + "color": "orange", + "value": 250 + }, + { + "color": "red", + "value": 265 + } + ] + }, + "unit": "volt" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 11, + "x": 0, + "y": 11 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop:v.timeRangeStop)\n |> filter(fn: (r) =>\n r._measurement == \"camp_power\" and\n r._field == \"voltage\"\n )\n |> aggregateWindow(every: 1h, fn: last)\n |> group(columns: [\"phase\"])\n |> yield(name: \"voltage\")", + "refId": "A" + } + ], + "title": "Voltage", + "transparent": true, + "type": "gauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "displayName": "${__field.labels['phase']}", + "mappings": [], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 0.5 + }, + { + "color": "green", + "value": 0.9 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 10, + "x": 11, + "y": 11 + }, + "id": 7, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop:v.timeRangeStop)\n |> filter(fn: (r) =>\n r._measurement == \"camp_power\" and\n r._field == \"pf\"\n )\n |> aggregateWindow(every: 1h, fn: last)\n |> group(columns: [\"phase\"])\n |> yield(name: \"pf\")", + "refId": "A" + } + ], + "title": "Powerfactor", + "transparent": true, + "type": "gauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "displayName": "Hz", + "mappings": [], + "max": 51.25, + "min": 48.8, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 47.5 + }, + { + "color": "yellow", + "value": 49.8 + }, + { + "color": "green", + "value": 49.95 + }, + { + "color": "yellow", + "value": 50.05 + }, + { + "color": "red", + "value": 50.2 + } + ] + }, + "unit": "rothz" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 3, + "x": 21, + "y": 11 + }, + "id": 8, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop:v.timeRangeStop)\n |> filter(fn: (r) =>\n r._measurement == \"sdm\" and\n r.name == \"Vorzelt\" and\n r._field == \"Frequency\"\n )\n |> aggregateWindow(every: v.windowPeriod, fn: last)\n |> yield(name: \"Hz\")", + "refId": "A" + } + ], + "title": "Frequency", + "transparent": true, + "type": "gauge" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 15 + }, + "id": 4, + "panels": [], + "title": "Historical", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 3600000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "transparent", + "value": null + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "amp" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "min", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop:v.timeRangeStop)\n |> filter(fn: (r) =>\n r._measurement == \"camp_power\" and\n r._field == \"current\"\n )\n |> group(columns: [\"phase\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean)\n |> yield(name: \"current\")", + "refId": "A" + } + ], + "title": "Amperage per Phase", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "description": "Available voltage from utility company", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 3600000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "volt" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 24 + }, + "id": 1, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "min", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop:v.timeRangeStop)\n |> filter(fn: (r) =>\n r._measurement == \"camp_power\" and\n r._field == \"voltage\"\n )\n |> group(columns: [\"phase\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean)\n |> yield(name: \"voltage\")", + "refId": "A" + } + ], + "title": "Grid Voltage", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 3600000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "watt" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 5, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "min", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop:v.timeRangeStop)\n |> filter(fn: (r) =>\n r._measurement == \"camp_power\" and\n r._field == \"power\"\n )\n |> group(columns: [\"phase\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean)\n |> yield(name: \"power\")", + "refId": "A" + } + ], + "title": "Power draw", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": 3600000, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "transparent" + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "amp" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 40 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "min", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "maxHeight": 600, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "bdoon42i61rlse" + }, + "query": "from(bucket: \"telegraf/autogen\")\n |> range(start: v.timeRangeStart, stop:v.timeRangeStop)\n |> filter(fn: (r) =>\n r._measurement == \"sdm\" and\n r._field =~ /L[123]_Current$/\n )\n |> group(columns: [\"name\", \"_field\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean)\n |> yield(name: \"current\")", + "refId": "A" + } + ], + "title": "Amperage per Phase", + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "0.27", + "value": "0.27" + }, + "hide": 0, + "label": "Arbeitspreis kWh", + "name": "energy_cost", + "options": [ + { + "selected": true, + "text": "0.27", + "value": "0.27" + } + ], + "query": "0.27", + "skipUrlSync": false, + "type": "textbox" + } + ] + }, + "time": { + "from": "2024-06-13T18:58:04.114Z", + "to": "2024-06-16T13:55:48.659Z" + }, + "timeRangeUpdatedDuringEditOrView": false, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s" + ] + }, + "timezone": "browser", + "title": "Camp Power", + "uid": "cdooob979frwge", + "version": 39, + "weekStart": "" +} diff --git a/logs/.keep b/logs/.keep new file mode 100644 index 0000000..e69de29 diff --git a/sdm.c b/sdm.c new file mode 100644 index 0000000..f30831b --- /dev/null +++ b/sdm.c @@ -0,0 +1,333 @@ +#include "sdm.h" + +#include "sdm230m_registers.h" +#include "sdm630m_registers.h" +#include "sdm72dm_registers.h" + +static char *sdm_typenames[] = { + "none", + "SDM230M", + "SDM630M", + "SDM72DM" +}; + +static void swap(sdm_values_t *value) { + uint16_t tmp; + + tmp = value->v16[0]; + value->v16[0] = value->v16[1]; + value->v16[1] = tmp; +} + +const char *sdm_gettypename(sdm_t *sdm) { + return sdm_typenames[sdm->type]; +} + +sdm_t *sdm_new(modbus_t *mb, int address, char *friendlyname, enum sdm_types type, int *error) { + sdm_t *sdm; + + if (mb == NULL) { + *error = SDM_MB_ERR; + return NULL; + } + + if (address <= 0 || address > 247) { + *error = SDM_ADDR_ERR; + return NULL; + } + + if (friendlyname == NULL) { + *error = SDM_NAME_ERR; + return NULL; + } + + sdm = (sdm_t *)calloc(sizeof(sdm_t), 1); + + if (sdm == NULL) { + *error = SDM_MEM_ERR; + return NULL; + } + + sdm->mb = mb; + sdm->address = address; + sdm->friendlyname = strdup(friendlyname); + + if (sdm->friendlyname == NULL) { + free(sdm); + *error = SDM_MEM_ERR; + + return NULL; + } + + sdm->type = type; + + switch (type) { + case SDM230M: + sdm->values = (sdm_values_t *)calloc(sizeof(sdm_values_t) * SMD230M_NRVAL, 1); + break; + case SDM630M: + sdm->values = (sdm_values_t *)calloc(sizeof(sdm_values_t) * SMD630M_NRVAL, 1); + break; + case SDM72DM: + sdm->values = (sdm_values_t *)calloc(sizeof(sdm_values_t) * SMD72DM_NRVAL, 1); + break; + default: + free(sdm->friendlyname); + free(sdm); + *error = SDM_MEM_ERR; + + return NULL; + } + + if (sdm->values == NULL) { + free(sdm->friendlyname); + free(sdm); + *error = SDM_MEM_ERR; + + return NULL; + } + + sdm->valid = false; + + + return sdm; +} + +int sdm_free(sdm_t *sdm) { + if (sdm == NULL) { + return SDM_EMPTY; + } + + free(sdm->friendlyname); + free(sdm->values); + free(sdm); + + return SDM_OK; +} + +int sdm_update(sdm_t *sdm) { + sdm_registers_t *regs; + sdm_values_t *value = sdm->values; + + sdm->valid = false; + + switch (sdm->type) { + case SDM230M: + regs = sdm230m_registers; + break; + case SDM630M: + regs = sdm630m_registers; + break; + case SDM72DM: + regs = sdm72dm_registers; + break; + case SDMNONE: + regs = NULL; + break; + } + + modbus_flush(sdm->mb); + modbus_set_slave(sdm->mb, sdm->address); + + while (regs->name != NULL) { + if (-1 == modbus_read_input_registers(sdm->mb, regs->regno, 2, value->v16)) { + return SDM_COMM_ERR; + } + + swap(value); + + regs++; + value++; + } + + sdm->valid = true; + + return SDM_OK; +} + +int sdm_print_csv_header(sdm_t *sdm, char **header) { + char tmp[4096], *ret; + int free = 4095, used = 0, count = 0; + sdm_registers_t *regs; + sdm_values_t *value = sdm->values; + + switch (sdm->type) { + case SDM230M: + regs = sdm230m_registers; + break; + case SDM630M: + regs = sdm630m_registers; + break; + case SDM72DM: + regs = sdm72dm_registers; + break; + case SDMNONE: + regs = NULL; + break; + } + + count = snprintf(tmp, free, "time;friendlyname;address;typename;valid;"); + + if (count < 0 || count >= free) { + //FIXME ERRORHANDLING + + return SDM_MEM_ERR; + } + + free -= count; + used += count; + count = 0; + + while (regs->name != NULL) { + count = snprintf(tmp + used, free, "%s;", regs->name); + if (count < 0 || count >= free) { + //FIXME ERRORHANDLING + + return SDM_MEM_ERR; + } + free -= count; + used += count; + count = 0; + regs++; + value++; + } + + ret = strdup(tmp); + if (ret == NULL) { + return SDM_MB_ERR; + } + + *header = ret; + return SDM_OK; + + return 0; +} + +int sdm_print_csv(sdm_t *sdm, char **csv) { + char tmp[4096], *ret; + int free = 4095, used = 0, count = 0; + time_t now; + sdm_registers_t *regs; + sdm_values_t *value = sdm->values; + + time(&now); + + tmp[4095] = 0; + + switch (sdm->type) { + case SDM230M: + regs = sdm230m_registers; + break; + case SDM630M: + regs = sdm630m_registers; + break; + case SDM72DM: + regs = sdm72dm_registers; + break; + case SDMNONE: + regs = NULL; + break; + } + + count = strftime(tmp, free, "%FT%TZ;", gmtime(&now)); + count += snprintf(tmp + count, free, "%s;%03d;%s;%s;", sdm->friendlyname, sdm->address, sdm_gettypename(sdm), sdm->valid?"true":"false"); + + if (count < 0 || count >= free) { + //FIXME ERRORHANDLING + + return SDM_MEM_ERR; + } + + free -= count; + used += count; + count = 0; + + while (regs->name != NULL && sdm->valid == true) { + count = snprintf(tmp + used, free, "%f;", value->vf); + if (count < 0 || count >= free) { + //FIXME ERRORHANDLING + + return SDM_MEM_ERR; + } + free -= count; + used += count; + count = 0; + regs++; + value++; + } + + ret = strdup(tmp); + if (ret == NULL) { + return SDM_MB_ERR; + } + + *csv = ret; + return SDM_OK; +} + +int sdm_print_json(sdm_t *sdm, char **json) { + char tmp[4096], *ret; + int free = 4095, used = 0, count = 0; + sdm_registers_t *regs; + sdm_values_t *value = sdm->values; + + tmp[4095] = 0; + + switch (sdm->type) { + case SDM230M: + regs = sdm230m_registers; + break; + case SDM630M: + regs = sdm630m_registers; + break; + case SDM72DM: + regs = sdm72dm_registers; + break; + case SDMNONE: + regs = NULL; + break; + } + + count = snprintf(tmp, free, "{\"name\":\"%s\",\"address\":%d,\"type\":\"%s\",\"valid\":%s", sdm->friendlyname, sdm->address, sdm_gettypename(sdm), sdm->valid?"true":"false"); + + if (count < 0 || count >= free) { + //FIXME ERRORHANDLING + + return SDM_MEM_ERR; + } + + free -= count; + used += count; + count = 0; + + while (regs->name != NULL && sdm->valid == true) { + count = snprintf(tmp + used, free, ",\"%s\":%f", regs->name, value->vf); + if (count < 0 || count >= free) { + //FIXME ERRORHANDLING + + return SDM_MEM_ERR; + } + free -= count; + used += count; + count = 0; + regs++; + value++; + } + + count = snprintf(tmp + used, free, "}"); + + if (count < 0 || count >= free) { + //FIXME ERRORHANDLING + + return SDM_MEM_ERR; + } + + ret = strdup(tmp); + if (ret == NULL) { + return SDM_MB_ERR; + } + + *json = ret; + return SDM_OK; +} + diff --git a/sdm.h b/sdm.h new file mode 100644 index 0000000..dc22fb1 --- /dev/null +++ b/sdm.h @@ -0,0 +1,56 @@ +#ifndef sdm_h +#define sdm_h + +#include +#include +#include +#include +#include +#include + +#define SDM_OK 0 +#define SDM_EMPTY -1 +#define SDM_MB_ERR -2 +#define SDM_ADDR_ERR -3 +#define SDM_NAME_ERR -4 +#define SDM_TYPE_ERR -5 +#define SDM_COMM_ERR -6 +#define SDM_NOT_IMPL -7 +#define SDM_MEM_ERR -8 + +enum sdm_types { + SDMNONE, + SDM230M, + SDM630M, + SDM72DM +}; + +typedef struct { + const char *name; + const char *unit; + const unsigned int regno; +} sdm_registers_t; + +typedef union { + uint16_t v16[2]; + float vf; +} __attribute__ ((packed)) sdm_values_t; + +typedef struct { + modbus_t *mb; + int address; + char *friendlyname; + enum sdm_types type; + sdm_values_t *values; + bool valid; +} sdm_t; + +sdm_t *sdm_new(modbus_t *mb, int address, char *friendlyname, enum sdm_types type, int *error); +int sdm_free(sdm_t *sdm); +int sdm_update(sdm_t *sdm); +const char *sdm_gettypename(sdm_t *sdm); +int sdm_print_csv(sdm_t *sdm, char **csv); +int sdm_print_csv_header(sdm_t *sdm, char **header); +int sdm_print_json(sdm_t *sdm, char **json); + +#endif diff --git a/sdm230m_registers.h b/sdm230m_registers.h new file mode 100644 index 0000000..e5bb8bc --- /dev/null +++ b/sdm230m_registers.h @@ -0,0 +1,35 @@ +#ifndef sdm230m_registers_h +#define sdm230m_registers_h + +#define SMD230M_NRVAL 24 + + +// 24 Messwerte +static sdm_registers_t sdm230m_registers[] = { + { "L-N Voltage", "V" , 0 }, + { "Current", "A" , 6 }, + { "Power", "W" , 12 }, + { "Apparent Power", "VA" , 18 }, + { "Reactive Power", "VAr" , 24 }, + { "Power Factor", "1" , 30 }, + { "Phase Angle", "°" , 36 }, + { "Frequency", "Hz" , 70 }, + { "Energy Import", "kWh" , 72 }, + { "Energy Export", "kWh" , 74 }, + { "Reactive Energy Import", "kVArh" , 76 }, + { "Reactive Energy Export", "kVArh" , 78 }, + { "Total Power Demand", "W" , 84 }, + { "Total Power Demand (Max)", "W" , 86 }, + { "Current Power Demand (Import)", "W" , 88 }, + { "Max Power Demand (Import)", "W" , 90 }, + { "Current Power Demand (Export)", "W" , 92 }, + { "Max Power Demand (Export)", "W" , 94 }, + { "L Current Demand", "A" , 258 }, + { "L Current Demand (Max)", "A" , 264 }, + { "Total Energy", "kWh" , 342 }, + { "Total Reactive Energy", "kVArh" , 344 }, + { "Total Energy (resettable)", "kWh" , 384 }, + { "Total Reactive Energy (resettable)", "kVArh" , 386 }, + { NULL, NULL , 0 } +}; +#endif diff --git a/sdm2mqtt.c b/sdm2mqtt.c new file mode 100644 index 0000000..e10d26f --- /dev/null +++ b/sdm2mqtt.c @@ -0,0 +1,163 @@ +#include +#include +#include + +#include +#include + +#include "sdm.h" +#include "config.h" + + +int main(int argc, char **argv) { + int nmeters, i, err; + + struct mosquitto *mosq; + modbus_t *mb; + sdm_t **sdms; + FILE **logfiles; + + char *printme; + char buf[4096]; + + mosquitto_lib_init(); + mosq = mosquitto_new(cfg.mosq_name, false, NULL); + + if (mosq == NULL) { + //FIXME + + return -1; + } + + err = mosquitto_connect(mosq, cfg.mosq_host, cfg.mosq_port, cfg.mosq_keepalive); + + if (err != MOSQ_ERR_SUCCESS) { + //FIXME + + return -1; + } + + mb = modbus_new_rtu(cfg.ser_device, cfg.ser_baud, cfg.ser_parity, cfg.ser_databits, cfg.ser_stopbits); + + if (mb == NULL) { + fprintf(stderr, "%s:%d: failed to create Modbus Context\n", __FILE__, __LINE__); + + return -1; + } + + if (modbus_connect(mb) == -1) { + fprintf(stderr, "%s:%d: Modbus connection failed: %s\n", __FILE__, __LINE__, modbus_strerror(errno)); + return -2; + } + + nmeters = 0; + while (cfg.meters[nmeters].friendlyname != NULL) { + nmeters++; + } + + sdms = (sdm_t **)calloc(sizeof(sdm_t *) * nmeters, 1); + logfiles = (FILE **)calloc(sizeof(FILE *) * nmeters, 1); + + for (i = 0; i < nmeters; i++) { + err = 0; + sdms[i] = sdm_new(mb, cfg.meters[i].addr, cfg.meters[i].friendlyname, cfg.meters[i].type, &err); + + if (sdms[i] == NULL) { + fprintf(stderr, "%s:%d: Failed to create meter %s\n", __FILE__, __LINE__, cfg.meters[i].friendlyname); + } + + snprintf(buf, 4095, "logs/%s.log", sdms[i]->friendlyname); + logfiles[i] = fopen(buf, "r"); + if (logfiles[i] == NULL) { + char *header; + + logfiles[i] = fopen(buf, "a"); + if (logfiles[i] == NULL) { + fprintf(stderr, "%s:%d: Failed to open logfile %s:\n", __FILE__, __LINE__, cfg.meters[i].friendlyname); + perror(""); + } + + if (sdm_print_csv_header(sdms[i], &header)) { + //FIXME + } + + fprintf(logfiles[i], "%s\n", header); + free(header); + } else { + logfiles[i] = freopen(buf, "a", logfiles[i]); + } + + if (logfiles[i] == NULL) { + fprintf(stderr, "%s:%d: Failed to open logfile %s:\n", __FILE__, __LINE__, cfg.meters[i].friendlyname); + perror(""); + } + } + + while (1) { + for (i = 0; i < nmeters; i++) { + if (sdm_update(sdms[i]) != SDM_OK) { + int errsv = errno; + fprintf(stderr, "%s:%d: %s: %s (%d)\n", __FILE__, __LINE__, modbus_strerror(errsv), sdms[i]->friendlyname, sdms[i]->address); + if (errsv == ECONNRESET || errsv == EBADF) { + // USB Connection failed? + // try to reconnect... + modbus_close(mb); + if (modbus_connect(mb) == -1) { + fprintf(stderr, "%s:%d: Modbus reconnect failed: %s\n", __FILE__, __LINE__, modbus_strerror(errno)); + sleep(1); + } + } + } + + if (sdm_print_csv(sdms[i], &printme) != SDM_OK) { + fprintf(stderr, "%s:%d: error in sdm_print_csv!\n", __FILE__, __LINE__); + } else { + fprintf(logfiles[i], "%s\n", printme); + fflush(logfiles[i]); + free(printme); + } + if (sdm_print_json(sdms[i], &printme) != SDM_OK) { + fprintf(stderr, "%s:%d: error in sdm_print_json!\n", __FILE__, __LINE__); + } else { + int err; + //FIXME + char topic[256]; + + snprintf(topic, 255, "%s/%s", cfg.mosq_topicprefix, sdms[i]->friendlyname); + + err = mosquitto_publish(mosq, NULL, topic, strlen(printme), printme, 0, false); + if (err != MOSQ_ERR_SUCCESS) { + fprintf(stderr, "%s:%d: mosquitto error! (%d)\n", __FILE__, __LINE__, err); + err = mosquitto_reconnect(mosq); + if (err != MOSQ_ERR_SUCCESS) { + //FIXME + fprintf(stderr, "%s:%d: Mosquitto reconnect failed.\n", __FILE__, __LINE__); + } + + err = mosquitto_publish(mosq, NULL, topic, strlen(printme), printme, 0, false); + if (err != MOSQ_ERR_SUCCESS) { + //FIXME + fprintf(stderr, "%s:%d: publish failed after reconnect.\n", __FILE__, __LINE__); + } + } + free(printme); + } + usleep(10000); + } + } + + for (i = 0; i < nmeters; i++) { + sdm_free(sdms[i]); + fclose(logfiles[i]); + } + free(logfiles); + free(sdms); + modbus_close(mb); + //broken: "free(): double free detected in tcache 2" + //modbus_free(mb); + + mosquitto_lib_cleanup(); + + return 0; +} + diff --git a/sdm2mqtt.service b/sdm2mqtt.service new file mode 100644 index 0000000..53a8917 --- /dev/null +++ b/sdm2mqtt.service @@ -0,0 +1,12 @@ +[Unit] +Description=SDM-Series Energy Meter MQTT Bridge +After=multi-user.service + +[Service] +Type=simple +WorkingDirectory=$PWD +ExecStart=sdm2mqtt +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/sdm630m_registers.h b/sdm630m_registers.h new file mode 100644 index 0000000..c41dbe1 --- /dev/null +++ b/sdm630m_registers.h @@ -0,0 +1,95 @@ +#ifndef sdm630m_registers_h +#define sdm630m_registers_h + +#define SMD630M_NRVAL 85 + +// 85 Messwerte +static sdm_registers_t sdm630m_registers[] = { + { "L1-N Voltage", "V" , 0 }, + { "L2-N Voltage", "V" , 2 }, + { "L3-N Voltage", "V" , 4 }, + { "L1 Current", "A" , 6 }, + { "L2 Current", "A" , 8 }, + { "L3 Current", "A" , 10 }, + { "L1 Power", "W" , 12 }, + { "L2 Power", "W" , 14 }, + { "L3 Power", "W" , 16 }, + { "L1 Apparent Power", "VA" , 18 }, + { "L2 Apparent Power", "VA" , 20 }, + { "L3 Apparent Power", "VA" , 22 }, + { "L1 Reactive Power", "VAr" , 24 }, + { "L2 Reactive Power", "VAr" , 26 }, + { "L3 Reactive Power", "VAr" , 28 }, + { "L1 Power Factor", "1" , 30 }, + { "L2 Power Factor", "1" , 32 }, + { "L3 Power Factor", "1" , 34 }, + { "L1 Phase Angle", "°" , 36 }, + { "L2 Phase Angle", "°" , 38 }, + { "L3 Phase Angle", "°" , 40 }, + { "Average Voltage", "V" , 42 }, + { "Average Current", "A" , 46 }, + { "Current (Sum)", "A" , 48 }, + { "Total Power", "W" , 52 }, + { "Total Apparent Power", "VA" , 56 }, + { "Total Reactive Power", "VAr" , 60 }, + { "Total Power Factor", "1" , 62 }, + { "Total Phase Angle", "°" , 66 }, + { "Frequency", "Hz" , 70 }, + { "Energy Import", "kWh" , 72 }, + { "Energy Export", "kWh" , 74 }, + { "Reactive Energy Import", "kVArh" , 76 }, + { "Reactive Energy Export", "kVArh" , 78 }, + { "Total VAh", "kVAh" , 80 }, + { "Total Ah", "kAh" , 82 }, + { "Total Power Demand", "W" , 84 }, + { "Total Power Demand (Max)", "W" , 86 }, + { "Total Apparent Power Demand", "VA" , 100 }, + { "Total Apparent Power Demand (Max)", "VA" , 102 }, + { "Neutral Current Demand", "A" , 104 }, + { "Neutral Current Demand (Max)", "A" , 106 }, + { "L1-L2 Voltage", "V" , 200 }, + { "L2-L3 Voltage", "V" , 202 }, + { "L3-L1 Voltage", "V" , 204 }, + { "Average Line-Line Voltage", "V" , 206 }, + { "Neutral Current", "A" , 224 }, + { "L1-N THD (U)", "%" , 234 }, + { "L2-N THD (U)", "%" , 236 }, + { "L3-N THD (U)", "%" , 238 }, + { "L1 THD (I)", "%" , 240 }, + { "L2 THD (I)", "%" , 242 }, + { "L3 THD (I)", "%" , 244 }, + { "Average THD (U)", "%" , 248 }, + { "Average THD (I)", "%" , 250 }, + { "L1 Current Demand", "A" , 258 }, + { "L2 Current Demand", "A" , 260 }, + { "L3 Current Demand", "A" , 262 }, + { "L1 Current Demand (Max)", "A" , 264 }, + { "L2 Current Demand (Max)", "A" , 266 }, + { "L3 Current Demand (Max)", "A" , 268 }, + { "L1-L2 THD (U)", "%" , 334 }, + { "L2-L3 THD (U)", "%" , 336 }, + { "L3-L1 THD (U)", "%" , 338 }, + { "Average L-L THD", "%" , 340 }, + { "Total Energy", "kWh" , 342 }, + { "Total Reactive Energy", "kVArh" , 344 }, + { "L1 Energy Import", "kWh" , 346 }, + { "L2 Energy Import", "kWh" , 348 }, + { "L3 Energy Import", "kWh" , 350 }, + { "L1 Energy Export", "kWh" , 352 }, + { "L2 Energy Export", "kWh" , 354 }, + { "L3 Energy Export", "kWh" , 356 }, + { "L1 Energy (Total)", "kWh" , 358 }, + { "L2 Energy (Total)", "kWh" , 360 }, + { "L3 Energy (Total)", "kWh" , 362 }, + { "L1 Reactive Energy Import", "kVArh" , 364 }, + { "L2 Reactive Energy Import", "kVArh" , 366 }, + { "L3 Reactive Energy Import", "kVArh" , 368 }, + { "L1 Reactive Energy Export", "kVArh" , 370 }, + { "L2 Reactive Energy Export", "kVArh" , 372 }, + { "L3 Reactive Energy Export", "kVArh" , 374 }, + { "L1 Reactive Energy (Total)", "kVArh" , 376 }, + { "L2 Reactive Energy (Total)", "kVArh" , 378 }, + { "L3 Reactive Energy (Total)", "kVArh" , 380 }, + { NULL, NULL , 0 } +}; +#endif diff --git a/sdm72dm_registers.h b/sdm72dm_registers.h new file mode 100644 index 0000000..2e28e43 --- /dev/null +++ b/sdm72dm_registers.h @@ -0,0 +1,20 @@ +#ifndef sdm72dm_registers_h +#define sdm72dm_registers_h + +#define SMD72DM_NRVAL 9 + + +// 9 Messwerte +static sdm_registers_t sdm72dm_registers[] = { + { "Power", "W" , 52 }, + { "Energy Import", "kWh" , 72 }, + { "Energy Export", "kWh" , 74 }, + { "Energy (Total)", "kWh" , 342 }, + { "Total Energy (resettable)", "kWh" , 384 }, + { "Energy Import (resettable)", "kWh" , 388 }, + { "Energy Export (resettable)", "kWh" , 390 }, + { "Power Import", "W" , 1280 }, + { "Power Export", "W" , 1282 }, + { NULL, NULL , 0 } +}; +#endif diff --git a/telegraf/sdm2mqtt.conf b/telegraf/sdm2mqtt.conf new file mode 100644 index 0000000..d850c24 --- /dev/null +++ b/telegraf/sdm2mqtt.conf @@ -0,0 +1,24 @@ +# # Read metrics from MQTT topic(s) +[[inputs.mqtt_consumer]] + servers = ["tcp://127.0.0.1:1883"] + topics = [ + "sdm2mqtt2/#", + ] + name_override = "sdm" + +# ## Username and password to connect MQTT server. +# # username = "telegraf" +# # password = "metricsmetricsmetricsmetrics" +# +# ## Optional TLS Config +# # tls_ca = "/etc/telegraf/ca.pem" +# # tls_cert = "/etc/telegraf/cert.pem" +# # tls_key = "/etc/telegraf/key.pem" +# ## Use TLS but skip chain & host verification +# # insecure_skip_verify = false +# + data_format = "json_v2" + [[inputs.mqtt_consumer.json_v2]] + [[inputs.mqtt_consumer.json_v2.object]] + path = "@this" + tags = [ "name", "address", "type" ]