Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Build Status](https://travis-ci.org/volkszaehler/mbmd.svg?branch=master)](https://travis-ci.org/volkszaehler/mbmd)

A daemon for collecting measurement data from smart meters and grid inverters over modbus.
A daemon for collecting measurement data from smart meters, grid inverters and SolarEdge batteries over modbus.

`mbmd` provides an http interface to smart meters and grid inverters with modbus interface.
Meter readings are made accessible through REST API and MQTT.
Expand All @@ -27,7 +27,7 @@ Modbus communication is possible over RS485 connections as well as TCP sockets.
## Requirements

You'll need:
* A supported Modbus/RTU smart meter OR an supported Modbus/TCP SunSpec-compatible grid inverter.
* A supported Modbus/RTU smart meter OR a supported Modbus/TCP SunSpec-compatible grid inverter.
* In case of Modbus/RTU: A USB RS485 adapter. See [USB-ISO-RS485 project](https://github.com/gonium/usb-iso-rs485) for a home-grown adapter.
* Optionally an RS485 to Ethernet converter (see [SO discussion](https://stackoverflow.com/questions/59459877/is-rtu-over-tcp-a-spec-conforming-modbus-application))

Expand Down Expand Up @@ -310,6 +310,9 @@ SunSpec devices can host multiple subdevices, e.g. to expose a meter attached to

./mbmd run -a 192.168.0.44:502 -d FRONIUS:1.0 -d FRONIUS:1.1

SolarEdge batteries can be included with other devices:

./mbmd run -d SUNS:1,SUNS:1.1,SUNS:2,SE-BAT:1,SE-BAT:1.1 -a 192.168.168.168:1502 --timeout 10s --mqtt-clientid mbmd --mqtt-homie homie --mqtt-topic mbmd --mqtt-broker tcp://192.168.168.169:1883

# Releases

Expand Down
3 changes: 2 additions & 1 deletion cmd/confighandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ func (conf *DeviceConfigHandler) createDeviceForManager(
meterType = strings.ToUpper(meterType)

var isSunspec bool
sunspecTypes := []string{"FRONIUS", "KOSTAL", "KACO", "SE", "SMA", "SOLAREDGE", "STECA", "SUNS", "SUNSPEC"}
// Modify this line to add the battery device types:
sunspecTypes := []string{"FRONIUS", "KOSTAL", "KACO", "SE", "SMA", "SOLAREDGE", "STECA", "SUNS", "SUNSPEC", "SE-BAT", "SOLAREDGE-BAT", "SE-BATTERY", "SOLAREDGE-BATTERY"}
for _, t := range sunspecTypes {
if t == meterType {
isSunspec = true
Expand Down
2 changes: 1 addition & 1 deletion cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func scan(cmd *cobra.Command, args []string) {
// create devices
devices := make([]meters.Device, 0)
if _, ok := conn.(*meters.TCP); ok {
suns := sunspec.NewDevice("SUNS")
suns := sunspec.NewDevice("SUNS", 0) // Pass 0 as the default subdevice
devices = append(devices, suns)
} else {
for t := range rs485.Producers {
Expand Down
444 changes: 248 additions & 196 deletions meters/measurement_enumer.go

Large diffs are not rendered by default.

31 changes: 30 additions & 1 deletion meters/measurements.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,22 @@ const (
// Battery
ChargeState
BatteryVoltage

PhaseAngle

// Battery specific measurements
BatterySOH // State of Health
BatterySOC // State of Charge
BatteryTemperature // Battery temperature
BatteryCurrent // Battery current
BatteryPower // Battery power
BatteryStatus // Battery status code
BatteryCriticalFault // Battery critical fault
BatteryWarningFault // Battery warning fault
BatteryAlertFault // Battery alert fault
BatteryRatedCapacity // Battery rated capacity
BatteryMaxPowerCharge // Battery max charge power
BatteryMaxPowerDischarge // Battery max discharge power
BatteryAvailableEnergy // Battery available energy
)

var iec = map[Measurement][]string{
Expand Down Expand Up @@ -243,6 +257,21 @@ var iec = map[Measurement][]string{
ChargeState: {"Charge State", "%"},
BatteryVoltage: {"Battery Voltage", "V"},
PhaseAngle: {"Phase Angle", "°"},

// Battery specific measurements
BatterySOH: {"Battery State of Health", "%"},
BatterySOC: {"Battery State of Charge", "%"},
BatteryTemperature: {"Battery Temperature", "°C"},
BatteryCurrent: {"Battery Current", "A"},
BatteryPower: {"Battery Power", "W"},
BatteryStatus: {"Battery Status", ""},
BatteryCriticalFault: {"Battery Critical Fault", ""},
BatteryWarningFault: {"Battery Warning Fault", ""},
BatteryAlertFault: {"Battery Alert Fault", ""},
BatteryRatedCapacity: {"Battery Rated Capacity", "kWh"},
BatteryMaxPowerCharge: {"Battery Max Power Charge", "W"},
BatteryMaxPowerDischarge: {"Battery Max Power Discharge", "W"},
BatteryAvailableEnergy: {"Battery Available Energy", "kWh"},
}

// MarshalText implements encoding.TextMarshaler
Expand Down
266 changes: 266 additions & 0 deletions meters/sunspec/solaredge_battery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package sunspec

import (
"encoding/binary"
"fmt"
"math"
"strings"
"time"

"github.com/grid-x/modbus"
"github.com/volkszaehler/mbmd/meters"
)

const (
// Battery register addresses (in decimal, exactly as in the Perl script)
batteryRegisterBase1 = 57600 // 0xE100 - Battery 1
batteryRegisterBase2 = 57856 // 0xE200 - Battery 2
)

// SolarEdgeBattery implements the meters.Device interface for SolarEdge battery
type SolarEdgeBattery struct {
SunSpec
}

// NewSolarEdgeBatteryDevice creates a SolarEdge battery device
func NewSolarEdgeBatteryDevice(subdevice int) *SolarEdgeBattery {
dev := &SolarEdgeBattery{
SunSpec: SunSpec{
subdevice: subdevice,
descriptor: meters.DeviceDescriptor{
Type: "SE-BAT",
Manufacturer: "SolarEdge",
Model: "Home Battery",
SubDevice: subdevice,
},
},
}

return dev
}

// readStringFromRegisters reads a string from Modbus registers
func readStringFromRegisters(data []byte) string {
result := ""

// Process each byte individually
for i := 0; i < len(data); i++ {
// Only add non-null bytes to the string
if data[i] != 0 {
result += string(data[i])
}
}

return strings.TrimSpace(result)
}

// Initialize implements the meters.Device interface
func (d *SolarEdgeBattery) Initialize(client modbus.Client) error {
baseAddr := d.getBaseAddress()

// Read manufacturer name (16 registers)
manufacturerData, err := client.ReadHoldingRegisters(baseAddr, 16)
if err == nil && len(manufacturerData) >= 32 {
manufacturer := readStringFromRegisters(manufacturerData)
if len(manufacturer) > 0 {
d.descriptor.Manufacturer = manufacturer
}
}

// Read model name (16 registers)
modelData, err := client.ReadHoldingRegisters(baseAddr + 16, 16)
if err == nil && len(modelData) >= 32 {
model := readStringFromRegisters(modelData)
if len(model) > 0 {
d.descriptor.Model = model
}
}

// Read firmware version (16 registers)
versionData, err := client.ReadHoldingRegisters(baseAddr + 32, 16)
if err == nil && len(versionData) >= 32 {
version := readStringFromRegisters(versionData)
if len(version) > 0 {
d.descriptor.Version = version
}
}

// Read serial number (16 registers)
serialData, err := client.ReadHoldingRegisters(baseAddr + 48, 16)
if err == nil && len(serialData) >= 32 {
serial := readStringFromRegisters(serialData)
if len(serial) > 0 {
d.descriptor.Serial = serial
}
}

return nil
}

// getBaseAddress returns the correct base register address for the battery
func (d *SolarEdgeBattery) getBaseAddress() uint16 {
if d.subdevice == 0 {
return batteryRegisterBase1
}
return batteryRegisterBase2
}

// readSEFloat reads a SolarEdge float value from the given data at the specified offset
func readSEFloat(data []byte, offset int) (float64, error) {
if offset+3 >= len(data) {
return 0, fmt.Errorf("not enough data")
}

// Extract as SEFLOAT (little endian)
rawBytes := []byte{data[offset+1], data[offset], data[offset+3], data[offset+2]}
bits := binary.LittleEndian.Uint32(rawBytes)
value := float64(math.Float32frombits(bits))

// Skip NaN and Infinity values
if math.IsNaN(value) || math.IsInf(value, 0) {
return 0, fmt.Errorf("invalid value")
}

return value, nil
}

// Probe implements the meters.Device interface
func (d *SolarEdgeBattery) Probe(client modbus.Client) (meters.MeasurementResult, error) {
// Try to read the State of Energy register at offset 0x84 (132)
baseAddr := d.getBaseAddress()

// Read SOE register directly
b, err := client.ReadHoldingRegisters(baseAddr + 0x84, 2)
if err != nil {
return meters.MeasurementResult{}, err
}

if len(b) < 4 {
return meters.MeasurementResult{}, fmt.Errorf("insufficient data received")
}

// Extract the SOE value
soc, err := readSEFloat(b, 0)
if err != nil {
return meters.MeasurementResult{}, err
}

return meters.MeasurementResult{
Measurement: meters.BatterySOC,
Value: soc,
Timestamp: time.Now(),
}, nil
}

// Query implements the meters.Device interface
func (d *SolarEdgeBattery) Query(client modbus.Client) ([]meters.MeasurementResult, error) {
res := make([]meters.MeasurementResult, 0, 9)
timestamp := time.Now()
baseAddr := d.getBaseAddress()

// Read registers in chunks to minimize Modbus traffic

// First chunk: Read the rated energy and power capabilities
ratedEnergyData, err := client.ReadHoldingRegisters(baseAddr + 0x42, 8)
if err == nil && len(ratedEnergyData) >= 16 {
// Rated Energy (0x42)
if value, err := readSEFloat(ratedEnergyData, 0); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatteryRatedCapacity,
Value: value * 0.001, // Convert Wh to kWh
Timestamp: timestamp,
})
}

// Max Charge Power (0x44)
if value, err := readSEFloat(ratedEnergyData, 4); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatteryMaxPowerCharge,
Value: value,
Timestamp: timestamp,
})
}

// Max Discharge Power (0x46)
if value, err := readSEFloat(ratedEnergyData, 8); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatteryMaxPowerDischarge,
Value: value,
Timestamp: timestamp,
})
}
}

// Second chunk: Read the battery metrics (temperature through voltage, current, power)
batteryData, err := client.ReadHoldingRegisters(baseAddr + 0x6C, 10)
if err == nil && len(batteryData) >= 20 {
// Temperature (0x6C)
if value, err := readSEFloat(batteryData, 0); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatteryTemperature,
Value: value,
Timestamp: timestamp,
})
}

// Voltage (0x70)
if value, err := readSEFloat(batteryData, 8); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatteryVoltage,
Value: value,
Timestamp: timestamp,
})
}

// Current (0x72)
if value, err := readSEFloat(batteryData, 12); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatteryCurrent,
Value: value,
Timestamp: timestamp,
})
}

// Power (0x74) - Negated to match the Perl script
if value, err := readSEFloat(batteryData, 16); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatteryPower,
Value: -value, // Negate the value like the Perl script does
Timestamp: timestamp,
})
}
}

// Third chunk: Read the energy, SOH, SOC registers
energyData, err := client.ReadHoldingRegisters(baseAddr + 0x80, 6)
if err == nil && len(energyData) >= 12 {
// Available Energy (0x80)
if value, err := readSEFloat(energyData, 0); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatteryAvailableEnergy,
Value: value * 0.001, // Convert Wh to kWh
Timestamp: timestamp,
})
}

// State of Health (0x82)
if value, err := readSEFloat(energyData, 4); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatterySOH,
Value: value,
Timestamp: timestamp,
})
}

// State of Energy (0x84)
if value, err := readSEFloat(energyData, 8); err == nil {
res = append(res, meters.MeasurementResult{
Measurement: meters.BatterySOC,
Value: value,
Timestamp: timestamp,
})
}
}

return res, nil
}
Loading