diff --git a/README.md b/README.md index 983c32b..4a5ebf1 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,18 @@ **This project was renamed and refactored to be usable for other devices than just SolarEdge.** -Collects data from Modbus devices like SolarEdge inverters and exports it as JSON, XML or to Prometheus/InfluxDB and Mqtt. +Collects data from Modbus devices like inverters, batteries or energy meters and exports it as JSON, XML or to Prometheus/InfluxDB and Mqtt. The data is queried over Modbus TCP. For now, the following devices are supported: - SolarEdge (over SunSpec) - Inverters - Energy meters - Batteries (StorEdge, BYD, ...) +- Janitza + - Energy Meters + - `UMG 96-PA` with `UMG 96-PA-RCM-EL` Ethernet module + +Other device models that use similar Modbus registers may also work. It's possible to query any number of devices at the same time. @@ -90,3 +95,7 @@ You don't have to use the SolarEdge app, just connect using your phone's WiFi se Modbus TCP in the configuration interface. You can also use the SolarEdge SetApp to enable Modbus TCP, but that will require you to be a verified installer and grant you only read-only access otherwise. + +### Janitza + +This exporter was tested with the `UMG 96-PA` power analyzer with the `UMG 96-PA-RCM-EL` Ethernet module. It should also be possible to use an external Modbus gateway instead of the `UMG 96-PA-RCM-EL`. \ No newline at end of file diff --git a/src/EnergyExporter/Controllers/DevicesController.cs b/src/EnergyExporter/Controllers/DevicesController.cs index f11bb45..905e293 100644 --- a/src/EnergyExporter/Controllers/DevicesController.cs +++ b/src/EnergyExporter/Controllers/DevicesController.cs @@ -54,4 +54,19 @@ public ActionResult GetSolarEdgeMeter(string id) => public ActionResult GetSolarEdgeBatteries(string id) => _deviceService.Devices.OfType().FirstOrDefault(d => d.DeviceIdentifier == id) ?? (ActionResult)NotFound(); + + [HttpGet("janitza")] + public IEnumerable GetJanitzaDevices() + { + // We have to cast to object here, so polymorphism is respected when serializing the objects. + return _deviceService.Devices.OfType().Select(device => (object)device); + } + + [HttpGet("janitza/power-analyzers")] + public IEnumerable GetJanitzaPowerAnalyzers() => _deviceService.Devices.OfType(); + + [HttpGet("janitza/power-analyzers/{id}")] + public ActionResult GetJanitzaPowerAnalyzer(string id) => + _deviceService.Devices.OfType().FirstOrDefault(d => d.DeviceIdentifier == id) + ?? (ActionResult)NotFound(); } diff --git a/src/EnergyExporter/Devices/JanitzaDevice.cs b/src/EnergyExporter/Devices/JanitzaDevice.cs new file mode 100644 index 0000000..e7b30bf --- /dev/null +++ b/src/EnergyExporter/Devices/JanitzaDevice.cs @@ -0,0 +1,10 @@ +namespace EnergyExporter.Devices; + +public abstract class JanitzaDevice : IDevice +{ + /// + public abstract string DeviceType { get; } + + /// + public abstract string DeviceIdentifier { get; } +} diff --git a/src/EnergyExporter/Devices/JanitzaPowerAnalyzer.cs b/src/EnergyExporter/Devices/JanitzaPowerAnalyzer.cs new file mode 100644 index 0000000..f9a0692 --- /dev/null +++ b/src/EnergyExporter/Devices/JanitzaPowerAnalyzer.cs @@ -0,0 +1,281 @@ +using EnergyExporter.InfluxDb; +using EnergyExporter.Modbus; +using EnergyExporter.Prometheus; +using Newtonsoft.Json; + +namespace EnergyExporter.Devices; + +// https://www.janitza.de/files/download/manuals/current/UMG96-PA/firmware-v3/janitza-mal-umg96pa-fw3-de.pdf +// https://www.janitza.de/files/download/manuals/current/UMG96-PA/firmware-v3/janitza-mal-umg96pa-fw3-en.pdf + +[InfluxDbMeasurement("janitza_power_analyzer")] +public class JanitzaPowerAnalyzer : JanitzaDevice +{ + public const ushort ModbusAddress = 0; + + /// + public override string DeviceType => "JanitzaPowerAnalyzer"; + + [ModbusRegister(911)] + [InfluxDbMetric("serial_number")] + public uint SerialNumber { get; init; } + + [ModbusRegister(19000)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_voltage_p1", "Voltage on phase 1")] + [InfluxDbMetric("voltage_p1")] + public float VoltageP1 { get; init; } + + [ModbusRegister(19002)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_voltage_p2", "Voltage on phase 2")] + [InfluxDbMetric("voltage_p2")] + public float VoltageP2 { get; init; } + + [ModbusRegister(19004)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_voltage_p3", "Voltage on phase 3")] + [InfluxDbMetric("voltage_p3")] + public float VoltageP3 { get; init; } + + [ModbusRegister(19006)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_voltage_p1_to_p2", "Voltage between phase 1 and 2")] + [InfluxDbMetric("voltage_p1_to_p2")] + public float VoltageP1ToP2 { get; init; } + + [ModbusRegister(19008)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_voltage_p2_to_p3", "Voltage between phase 2 and 3")] + [InfluxDbMetric("voltage_p2_to_p3")] + public float VoltageP2ToP3 { get; init; } + + [ModbusRegister(19010)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_voltage_p3_to_p1", "Voltage between phase 3 and 1")] + [InfluxDbMetric("voltage_p3_to_p1")] + public float VoltageP3ToP1 { get; init; } + + [ModbusRegister(19012)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_current_p1", "Current on phase 1")] + [InfluxDbMetric("current_p1")] + public float CurrentP1 { get; init; } + + [ModbusRegister(19014)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_current_p2", "Current on phase 2")] + [InfluxDbMetric("current_p2")] + public float CurrentP2 { get; init; } + + [ModbusRegister(19016)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_current_p3", "Current on phase 3")] + [InfluxDbMetric("current_p3")] + public float CurrentP3 { get; init; } + + [ModbusRegister(19018)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_current_sum", "Vector sum of current")] + [InfluxDbMetric("current_sum")] + public float CurrentSum { get; init; } + + [ModbusRegister(19020)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_real_power_p1", "Real power on phase 1")] + [InfluxDbMetric("real_power_p1")] + public float RealPowerP1 { get; init; } + + [ModbusRegister(19022)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_real_power_p2", "Real power on phase 2")] + [InfluxDbMetric("real_power_p2")] + public float RealPowerP2 { get; init; } + + [ModbusRegister(19024)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_real_power_p3", "Real power on phase 3")] + [InfluxDbMetric("real_power_p3")] + public float RealPowerP3 { get; init; } + + [ModbusRegister(19026)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_real_power_sum", "Sum of real power")] + [InfluxDbMetric("real_power_sum")] + public float RealPowerSum { get; init; } + + [ModbusRegister(19028)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_apparent_power_p1", "Apparent power on phase 1")] + [InfluxDbMetric("apparent_power_p1")] + public float ApparentPowerP1 { get; init; } + + [ModbusRegister(19030)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_apparent_power_p2", "Apparent power on phase 2")] + [InfluxDbMetric("apparent_power_p2")] + public float ApparentPowerP2 { get; init; } + + [ModbusRegister(19032)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_apparent_power_p3", "Apparent power on phase 3")] + [InfluxDbMetric("apparent_power_p3")] + public float ApparentPowerP3 { get; init; } + + [ModbusRegister(19034)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_apparent_power_sum", "Sum of apparent power")] + [InfluxDbMetric("apparent_power_sum")] + public float ApparentPowerSum { get; init; } + + [ModbusRegister(19036)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_reactive_power_p1", "Reactive power on phase 1")] + [InfluxDbMetric("reactive_power_p1")] + public float ReactivePowerP1 { get; init; } + + [ModbusRegister(19038)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_reactive_power_p2", "Reactive power on phase 2")] + [InfluxDbMetric("reactive_power_p2")] + public float ReactivePowerP2 { get; init; } + + [ModbusRegister(19040)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_reactive_power_p3", "Reactive power on phase 3")] + [InfluxDbMetric("reactive_power_p3")] + public float ReactivePowerP3 { get; init; } + + [ModbusRegister(19042)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_reactive_power_sum", "Sum of reactive power")] + [InfluxDbMetric("reactive_power_sum")] + public float ReactivePowerSum { get; init; } + + [ModbusRegister(19044)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_power_factor_p1", + "Fundamental power factor on phase 1")] + [InfluxDbMetric("power_factor_p1")] + public float PowerFactorP1 { get; init; } + + [ModbusRegister(19046)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_power_factor_p2", + "Fundamental power factor on phase 2")] + [InfluxDbMetric("power_factor_p2")] + public float PowerFactorP2 { get; init; } + + [ModbusRegister(19048)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_power_factor_p3", + "Fundamental power factor on phase 3")] + [InfluxDbMetric("power_factor_p3")] + public float PowerFactorP3 { get; init; } + + [ModbusRegister(19050)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_frequency", "Measured frequency")] + [InfluxDbMetric("frequency")] + public float Frequency { get; init; } + + [ModbusRegister(19052)] + [PrometheusMetric(MetricType.Gauge, "janitza_power_analyzer_rotation_field", + "Phase rotation field: 1=right, 0=none, -1=left")] + [InfluxDbMetric("rotation_field")] + public float RotationField { get; init; } + + [ModbusRegister(19062)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_real_energy_consumed_p1", + "Real energy consumed on phase 1")] + [InfluxDbMetric("real_energy_consumed_p1")] + public float RealEnergyConsumedP1 { get; init; } + + [ModbusRegister(19064)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_real_energy_consumed_p2", + "Real energy consumed on phase 2")] + [InfluxDbMetric("real_energy_consumed_p2")] + public float RealEnergyConsumedP2 { get; init; } + + [ModbusRegister(19066)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_real_energy_consumed_p3", + "Real energy consumed on phase 3")] + [InfluxDbMetric("real_energy_consumed_p3")] + public float RealEnergyConsumedP3 { get; init; } + + [ModbusRegister(19068)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_real_energy_consumed_sum", + "Sum of real energy consumed")] + [InfluxDbMetric("real_energy_consumed_sum")] + public float RealEnergyConsumedSum { get; init; } + + [ModbusRegister(19070)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_real_energy_delivered_p1", + "Real energy delivered on phase 1")] + [InfluxDbMetric("real_energy_delivered_p1")] + public float RealEnergyDeliveredP1 { get; init; } + + [ModbusRegister(19072)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_real_energy_delivered_p2", + "Real energy delivered on phase 2")] + [InfluxDbMetric("real_energy_delivered_p2")] + public float RealEnergyDeliveredP2 { get; init; } + + [ModbusRegister(19074)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_real_energy_delivered_p3", + "Real energy delivered on phase 3")] + [InfluxDbMetric("real_energy_delivered_p3")] + public float RealEnergyDeliveredP3 { get; init; } + + [ModbusRegister(19076)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_real_energy_delivered_sum", + "Sum of real energy delivered")] + [InfluxDbMetric("real_energy_delivered_sum")] + public float RealEnergyDeliveredSum { get; init; } + + [ModbusRegister(19078)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_apparent_energy_p1", "Apparent energy on phase 1")] + [InfluxDbMetric("apparent_energy_p1")] + public float ApparentEnergyP1 { get; init; } + + [ModbusRegister(19080)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_apparent_energy_p2", "Apparent energy on phase 2")] + [InfluxDbMetric("apparent_energy_p2")] + public float ApparentEnergyP2 { get; init; } + + [ModbusRegister(19082)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_apparent_energy_p3", "Apparent energy on phase 3")] + [InfluxDbMetric("apparent_energy_p3")] + public float ApparentEnergyP3 { get; init; } + + [ModbusRegister(19084)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_apparent_energy_sum", "Sum of apparent energy")] + [InfluxDbMetric("apparent_energy_sum")] + public float ApparentEnergySum { get; init; } + + [ModbusRegister(19094)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_reactive_energy_inductive_p1", + "Reactive energy, inductive, on phase 1")] + [InfluxDbMetric("reactive_energy_inductive_p1")] + public float ReactiveEnergyInductiveP1 { get; init; } + + [ModbusRegister(19096)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_reactive_energy_inductive_p2", + "Reactive energy, inductive, on phase 2")] + [InfluxDbMetric("reactive_energy_inductive_p2")] + public float ReactiveEnergyInductiveP2 { get; init; } + + [ModbusRegister(19098)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_reactive_energy_inductive_p3", + "Reactive energy, inductive, on phase 3")] + [InfluxDbMetric("reactive_energy_inductive_p3")] + public float ReactiveEnergyInductiveP3 { get; init; } + + [ModbusRegister(19100)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_reactive_energy_inductive_sum", + "Sum of reactive energy, inductive")] + [InfluxDbMetric("reactive_energy_inductive_sum")] + public float ReactiveEnergyInductiveSum { get; init; } + + [ModbusRegister(19102)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_reactive_energy_capacitive_p1", + "Reactive energy, capacitive, on phase 1")] + [InfluxDbMetric("reactive_energy_capacitive_p1")] + public float ReactiveEnergyCapacitiveP1 { get; init; } + + [ModbusRegister(19104)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_reactive_energy_capacitive_p2", + "Reactive energy, capacitive, on phase 2")] + [InfluxDbMetric("reactive_energy_capacitive_p2")] + public float ReactiveEnergyCapacitiveP2 { get; init; } + + [ModbusRegister(19106)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_reactive_energy_capacitive_p3", + "Reactive energy, capacitive, on phase 3")] + [InfluxDbMetric("reactive_energy_capacitive_p3")] + public float ReactiveEnergyCapacitiveP3 { get; init; } + + [ModbusRegister(19108)] + [PrometheusMetric(MetricType.Counter, "janitza_power_analyzer_reactive_energy_capacitive_sum", + "Sum of reactive energy, capacitive")] + [InfluxDbMetric("reactive_energy_capacitive_sum")] + public float ReactiveEnergyCapacitiveSum { get; init; } + + /// + [JsonIgnore] + public override string DeviceIdentifier => SerialNumber.ToString(); +} diff --git a/src/EnergyExporter/Modbus/ModbusReader.cs b/src/EnergyExporter/Modbus/ModbusReader.cs index c6cb1e4..4b93438 100644 --- a/src/EnergyExporter/Modbus/ModbusReader.cs +++ b/src/EnergyExporter/Modbus/ModbusReader.cs @@ -40,7 +40,7 @@ public async Task ReadDeviceAsync(byte unit, ushort startRegis _logger.LogDebug( "Reading {Device} at address {StartRegister} from {Host} and unit {Unit}.", typeof(TDevice).Name, - $"0x{startRegister}", + $"0x{startRegister:x}", _host, unit); diff --git a/src/EnergyExporter/Options/DevicesOptions.cs b/src/EnergyExporter/Options/DevicesOptions.cs index 0f76299..8c44d13 100644 --- a/src/EnergyExporter/Options/DevicesOptions.cs +++ b/src/EnergyExporter/Options/DevicesOptions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; namespace EnergyExporter.Options; @@ -9,8 +10,10 @@ public class DevicesOptions public const string Key = "Devices"; public SolarEdgeModbusDevice[] SolarEdge { get; init; } = Array.Empty(); + + public JanitzaModbusDevice[] Janitza { get; init; } = Array.Empty(); - public IEnumerable ModbusDevices => SolarEdge; + public IEnumerable ModbusDevices => SolarEdge.Cast().Concat(Janitza); public enum SolarEdgeModbusDeviceType { @@ -19,6 +22,11 @@ public enum SolarEdgeModbusDeviceType Battery } + public enum JanitzaModbusDeviceType + { + PowerAnalyzer + } + public class SolarEdgeModbusDevice : ModbusDevice { [Required] @@ -30,6 +38,13 @@ public class SolarEdgeModbusDevice : ModbusDevice public string? SerialNumberOverride { get; init; } } + public class JanitzaModbusDevice : ModbusDevice + { + [Required] + [EnumDataType(typeof(JanitzaModbusDeviceType))] + public JanitzaModbusDeviceType Type { get; init; } + } + public class ModbusDevice { [Required] diff --git a/src/EnergyExporter/Services/DeviceService.cs b/src/EnergyExporter/Services/DeviceService.cs index 263b65a..dc54c0d 100644 --- a/src/EnergyExporter/Services/DeviceService.cs +++ b/src/EnergyExporter/Services/DeviceService.cs @@ -58,6 +58,8 @@ public async Task QueryDevicesAsync() return modbusDevice switch { DevicesOptions.SolarEdgeModbusDevice solarEdgeModbusDevice => await QuerySolarEdgeModbusDeviceAsync( solarEdgeModbusDevice), + DevicesOptions.JanitzaModbusDevice janitzaModbusDevice => await QueryJanitzaModbusDeviceAsync( + janitzaModbusDevice), _ => throw new ArgumentOutOfRangeException() }; } @@ -90,4 +92,20 @@ private async Task QuerySolarEdgeModbusDeviceAsync(DevicesOptions.Solar return device; } + + private async Task QueryJanitzaModbusDeviceAsync(DevicesOptions.JanitzaModbusDevice modbusDevice) + { + ModbusReader modbusReader = _modbusReaderPool.ReaderFor(modbusDevice.Host, modbusDevice.Port); + + JanitzaDevice device = modbusDevice.Type switch + { + DevicesOptions.JanitzaModbusDeviceType.PowerAnalyzer => await modbusReader + .ReadDeviceAsync( + modbusDevice.Unit, + JanitzaPowerAnalyzer.ModbusAddress), + _ => throw new ArgumentOutOfRangeException() + }; + + return device; + } } diff --git a/src/EnergyExporter/appsettings.sample.json b/src/EnergyExporter/appsettings.sample.json index c8f145f..88f9d0c 100644 --- a/src/EnergyExporter/appsettings.sample.json +++ b/src/EnergyExporter/appsettings.sample.json @@ -53,6 +53,14 @@ "Index": 0, }, ], + "Janitza": [ + { + "Type": "PowerAnalyzer", + "Host": "192.168.42.100", + "Port": 502, + "Unit": 1, + }, + ], }, "Polling": { "IntervalSeconds": 15