Since I’m lacking Java skills I helped myself with a python script. Others may find it useful too, so I would like to share it with the community.
- The script returns all data found in the multicast packet issued by the SMA Homenager 2.0 with FW revision >= 2.03.4.R.
- The data are decoded, adjusted and returned in a JSON string.
- Packets with wrong protocol id are filtered.
- A C type structure allows adaption to future protocol modifications.
- The script can be used standalone for testing or in a DSL rule.
Python Code:
# -*- coding: utf-8 -*-.
"""SMA Home Manager 2.0, multicast interpreter.
MulticastIP 239.12.255.254, port 9522 is checked for SMA packets with
protocol ID 6069. The packet content is output in JSON format.
Packets with wrong protocol ID are dropped.
Content of field "StateDetail"
valid SMA packet ("None")
OSError: socket timeout ("timed out")
other OSError (depends on error)
wrong ID string ("NonSMA Packet")
wrong protocol ID after one retry ("Wrong PID")
missing or wrong serial number ("Wrong Serial Number")
Input argument: HM20 serial number in decimal format
Last change: Mar 22, 2022
@author: TomE
"""
import sys
from ctypes import BigEndianStructure, c_uint8, c_uint16, c_uint32, c_uint64, c_char
import json
from dataclasses import dataclass
import socket
MULTICAST_GRP = '239.12.255.254'
MULTICAST_PORT = 9522
class RetryException(Exception):
"""Exception used to signal that SMA packet needs to be reread."""
@dataclass
class SMApacket(BigEndianStructure):
"""Expected structure of received SMA packet.
Valid for SMA Home Manager 2.0, Firmware revision >= 2.03.4.R
Tot: Sum over all phases
L1, L2, L3: Power phases
pIn / pOut: Power reveived / delivered
eIn / eOut: Energy received / delivered
act: active power / energy [0.1 W / 1 Ws]
rea: reactive power / energy [0.1 Var / 1 Vars]
app: apparent power / energy [0.1 VA / 1 VAs]
cosPhi: Power factor
Freq: Frequency
"""
_pack_ = 1 # suppress padding
_fields_ = [
# Header
('IDstring', c_char * 4), # "S" "M" "A" 0x00
('DataLength', c_uint16), # 0x0004
('Tag0', c_uint16), # 0x02A0, tag0 ???
('Group', c_uint32), # 0x00000001, group 1
('DataSize', c_uint16), # 0x024C: 588 bytes incl group and data
# size field, but wo FW Rev ???
('Tag1', c_uint16), # 0x0010: „SMA Net 2“, Version 0
('ProtocolID', c_uint16), # 0x6069: Emeter protocol
('SUSyID', c_uint16), # 0x0174 (373d)
('Serial', c_uint32), # Serialnumber in Hex
('ticker', c_uint32), # ms since start
# Total power and energy
('Obis_01_04', c_uint32), ('Tot_pIn_act', c_uint32), # active in
('Obis_01_08', c_uint32), ('Tot_eIn_act', c_uint64),
('Obis_02_04', c_uint32), ('Tot_pOut_act', c_uint32), # active out
('Obis_02_08', c_uint32), ('Tot_eOut_act', c_uint64),
('Obis_03_04', c_uint32), ('Tot_pIn_rea', c_uint32), # reactive in
('Obis_03_08', c_uint32), ('Tot_eIn_rea', c_uint64),
('Obis_04_04', c_uint32), ('Tot_pOut_rea', c_uint32), # reactive out
('Obis_04_08', c_uint32), ('Tot_eOut_rea', c_uint64),
('Obis_09_04', c_uint32), ('Tot_pIn_app', c_uint32), # apparent in
('Obis_09_08', c_uint32), ('Tot_eIn_app', c_uint64),
('Obis_10_04', c_uint32), ('Tot_pOut_app', c_uint32), # apparent out
('Obis_10_08', c_uint32), ('Tot_eOut_app', c_uint64),
('Obis_13_04', c_uint32), ('Tot_cosPhi', c_uint32),
('Obis_14_04', c_uint32), ('Tot_Freq', c_uint32),
# Phase 1 power and energy
('Obis_21_04', c_uint32), ('L1_pIn_act', c_uint32),
('Obis_21_08', c_uint32), ('L1_eIn_act', c_uint64),
('Obis_22_04', c_uint32), ('L1_pOut_act', c_uint32),
('Obis_22_08', c_uint32), ('L1_eOut_act', c_uint64),
('Obis_23_04', c_uint32), ('L1_pIn_rea', c_uint32),
('Obis_23_08', c_uint32), ('L1_eIn_rea', c_uint64),
('Obis_24_04', c_uint32), ('L1_pOut_rea', c_uint32),
('Obis_24_08', c_uint32), ('L1_eOut_rea', c_uint64),
('Obis_29_04', c_uint32), ('L1_pIn_app', c_uint32),
('Obis_29_08', c_uint32), ('L1_eIn_app', c_uint64),
('Obis_30_04', c_uint32), ('L1_pOut_app', c_uint32),
('Obis_30_08', c_uint32), ('L1_eOut_app', c_uint64),
('Obis_31_04', c_uint32), ('L1_current', c_uint32),
('Obis_32_04', c_uint32), ('L1_voltage', c_uint32),
('Obis_33_04', c_uint32), ('L1_cosPhi', c_uint32),
# Phase 2 power and energy
('Obis_41_04', c_uint32), ('L2_pIn_act', c_uint32),
('Obis_41_08', c_uint32), ('L2_eIn_act', c_uint64),
('Obis_42_04', c_uint32), ('L2_pOut_act', c_uint32),
('Obis_42_08', c_uint32), ('L2_eOut_act', c_uint64),
('Obis_43_04', c_uint32), ('L2_pIn_rea', c_uint32),
('Obis_43_08', c_uint32), ('L2_eIn_rea', c_uint64),
('Obis_44_04', c_uint32), ('L2_pOut_rea', c_uint32),
('Obis_44_08', c_uint32), ('L2_eOut_rea', c_uint64),
('Obis_49_04', c_uint32), ('L2_pIn_app', c_uint32),
('Obis_49_08', c_uint32), ('L2_eIn_app', c_uint64),
('Obis_50_04', c_uint32), ('L2_pOut_app', c_uint32),
('Obis_50_08', c_uint32), ('L2_eOut_app', c_uint64),
('Obis_51_04', c_uint32), ('L2_current', c_uint32),
('Obis_52_04', c_uint32), ('L2_voltage', c_uint32),
('Obis_53_04', c_uint32), ('L2_cosPhi', c_uint32),
# Phase 3 power and energy
('Obis_61_04', c_uint32), ('L3_pIn_act', c_uint32),
('Obis_61_08', c_uint32), ('L3_eIn_act', c_uint64),
('Obis_62_04', c_uint32), ('L3_pOut_act', c_uint32),
('Obis_62_08', c_uint32), ('L3_eOut_act', c_uint64),
('Obis_63_04', c_uint32), ('L3_pIn_rea', c_uint32),
('Obis_63_08', c_uint32), ('L3_eIn_rea', c_uint64),
('Obis_64_04', c_uint32), ('L3_pOut_rea', c_uint32),
('Obis_64_08', c_uint32), ('L3_eOut_rea', c_uint64),
('Obis_69_04', c_uint32), ('L3_pIn_app', c_uint32),
('Obis_69_08', c_uint32), ('L3_eIn_app', c_uint64),
('Obis_70_04', c_uint32), ('L3_pOut_app', c_uint32),
('Obis_70_08', c_uint32), ('L3_eOut_app', c_uint64),
('Obis_71_04', c_uint32), ('L3_current', c_uint32),
('Obis_72_04', c_uint32), ('L3_voltage', c_uint32),
('Obis_73_04', c_uint32), ('L3_cosPhi', c_uint32),
# Firmware revision
('Code_90_00', c_uint32),
('Major', c_uint8), ('Minor', c_uint8),
('Build', c_uint8), ('Revision', c_char),
# End string
('EndString', c_uint32)
]
class DecodeSMA:
"""Decode the multicast data sent by SMA 'Home Manager 20'."""
HM20 = {} # dictionary for emeter data
def __init__(self):
"""Initialize some HM20 dictionary fields to default values."""
#
self.HM20['HM20state'] = 'OFFLINE'
self.HM20['DataValid'] = 'INVALID'
self.HM20['StateDetail'] = 'None'
def decode_header(self, data, s_number):
"""Decode header data from packet structure.
Packet content HM20state DataValid stateDetail Action
Invalid SMA header: OFFLINE INVALID NonSMA Packet Exit
Invalid serial number: ONLINE INVALID Wrong Serial Number Exit
Wrong protocol ID: ONLINE INVALID WrongPID Retry
Correct header and ID: ONLINE VALID None Exit
"""
# Check ID string, packet identifier and serial number
if ((data.IDstring.decode("ascii") == 'SMA') and
((f'{data.ProtocolID:x}') == '6069') and
(str(data.Serial) == s_number)):
# Fill dictionary with header data
self.HM20['HM20state'] = 'ONLINE'
self.HM20['DataValid'] = 'VALID'
self.HM20['StateDetail'] = 'None'
self.HM20['IDstring'] = data.IDstring.decode("ascii")
self.HM20['ProtocolID'] = f'{data.ProtocolID:x}'
self.HM20['DataSize'] = data.DataSize
self.HM20['Serial'] = data.Serial
self.HM20['SUSyID'] = f'{data.SUSyID:x}'
self.HM20['FWrev'] = ('{:d}.{:d}.{:d}.{:s}'.
format(data.Major,
data.Minor,
data.Build,
data.Revision.decode('ascii')))
# Fill dictionary with phase data
for i in ['Tot', 'L1', 'L2', 'L3']:
self.decode_phase(data, i)
return
# Something went wrong, do some additional checks to provide more
# information in field 'StateDetail'
if data.IDstring.decode("ascii") != 'SMA':
self.HM20['StateDetail'] = 'NonSMA Packet'
return
# Fill dict with some more information
self.HM20['HM20state'] = 'ONLINE'
self.HM20['IDstring'] = data.IDstring.decode("ascii")
self.HM20['ProtocolID'] = f'{data.ProtocolID:x}'
# Protocol ID ok?
if (f'{data.ProtocolID:x}') != '6069':
# try to get the next packet with correct protocol ID
self.HM20['StateDetail'] = 'WrongPID'
raise RetryException
# ID string and protocol ID are ok, so serial number must be wrong
# store the number found
self.HM20['Serial'] = data.Serial
self.HM20['StateDetail'] = 'Wrong Serial Number'
def decode_phase(self, p_data, phase):
"""Decode phase data from structure and fill dict with adjusted values.
act: active power / energy [W / kWh]
rea: reactive power / energy [Var / kVarh]
app: apparent power / energy [VA / kVAh]
"""
#
p_list = ('_pIn_act', '_pIn_rea', '_pIn_app',
'_pOut_act', '_pOut_rea', '_pOut_app')
e_list = ('_eIn_act', '_eIn_rea', '_eIn_app',
'_eOut_act', '_eOut_rea', '_eOut_app')
for (i, j) in zip(p_list, e_list):
self.HM20[phase + i] = getattr(p_data, (phase + i)) / 10
self.HM20[phase + j] = getattr(p_data, (phase + j)) / 3600000
# Content of 'Total' block differs a little bit from 'Phase' blocks
if phase == "Tot":
for i in ('_cosPhi', '_Freq'):
self. HM20[phase + i] = getattr(p_data, (phase + i)) / 1000
else:
for i in ('_current', '_voltage', '_cosPhi'):
self. HM20[phase + i] = getattr(p_data, (phase + i)) / 1000
class MultiCastReception():
"""Handle multicast reception of SMA packets."""
def __init__(self, mc_grp, mc_port):
"""Initialize packet reception from multicast port."""
#
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
socket.IPPROTO_UDP)
# socket.setsockopt(level, optname, value: int)
# Allow reuse of socket (to allow another instance of python running
# this script binding to the same ip/port)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("", mc_port))
self.mreq = (socket.inet_aton(mc_grp)
+ socket.inet_aton(str(socket.INADDR_ANY)))
def join_multicast(self):
"""Join multicast group."""
#
self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
self.mreq)
self.sock.settimeout(0.5) # 500 ms allow reception of two packets
def close_multicast(self):
"""Close connection."""
#
self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP,
self.mreq)
self.sock.close()
def main():
"""."""
#
try:
# Extract serial number from command line arguments
serial_number = 0
if (len(sys.argv) == 2 and sys.argv[1].isdigit()):
serial_number = sys.argv[1]
# Init packet decoding
sma = DecodeSMA()
# Init packet reception from multicast port
mcr = MultiCastReception(MULTICAST_GRP, MULTICAST_PORT)
mcr.join_multicast()
for i in range(2): # allow for one retry
# Instantiate packet class here to get
# an empty SMA structure in each loop
packet = SMApacket()
# receive data
mcr.sock.recv_into(packet)
# decode data
try:
sma.decode_header(packet, serial_number)
break
except RetryException: # retry because of wrong protocol id
pass
# Clean up after decode
mcr.close_multicast()
except OSError as error: # e.g. wrong multicast group or packet size
sma.HM20['StateDetail'] = 'OSError: ' + str(error)
finally:
# Finally dump data in json format
print(json.dumps(sma.HM20))
# print(json.dumps(sma.HM20, indent=4))
if __name__ == '__main__':
main()
Some points worth to mention:
- I can and will not guarantee that the script works in all systems. It has been tested in my system standalone on WIN 10 and both standalone on Debian bullseye and integrated in a openHAB 3.2 / DSL rule. I also can’t give any support with UI based integration since I use text based DSL rules only.
- The script doesn’t support multiple Home Manager entities.
- I strongly recommend to test the function standalone first and than in a test (not productive) environment.
- If you feel not comfortable with python or scripts in general I recommend to use the current binding and wait for an update. The wrong data can be filtered in a rule or by means of a js transformation.
Standalone usage
To start the script standalone on the command line type (add your HM serial number):
/usr/bin/python3 sma_emeter.py 0123456789
Integration in openHAB
1. Items:
Group gHomeManager
String DataValid (gHomeManager)
String HM20state (gHomeManager)
String IDstring (gHomeManager)
String ProtocolID (gHomeManager)
Number DataSize (gHomeManager)
String SUSyID "HM SUSy-ID" (gHomeManager)
Number Serial "HM Serialnumber" (gHomeManager)
String FWrev "FW revision [%s]" (gHomeManager)
Number Tot_pIn_act "Bezogene Leistung [%.3f W]" (gHomeManager)
Number Tot_pIn_rea "Bezogene Blindleistung [%d Var]" (gHomeManager)
Number Tot_pIn_app "Bezogene Scheinleistung [%d VA]" (gHomeManager)
Number Tot_eIn_act "Bezogene Wirkenergie [%d kWh]" (gHomeManager)
Number Tot_eIn_rea "Bezogene Blindenergie [%d kVarh]" (gHomeManager)
Number Tot_eIn_app "Bezogene Scheinenergie [%d kVAh]" (gHomeManager)
Number Tot_pOut_act "Eingespeiste Leistung [%.3f W]" (gHomeManager)
Number Tot_pOut_rea "Eingespeiste Blindleistung [%d Var]" (gHomeManager)
Number Tot_pOut_app "Eingespeiste Scheinleistung [%d VA]" (gHomeManager)
Number Tot_eOut_act "Eingespeiste Wirkenergie [%d kWh]" (gHomeManager)
Number Tot_eOut_rea "Eingespeiste Blindenergie [%d kVarh]" (gHomeManager)
Number Tot_eOut_app "Eingespeiste Scheinenergie [%d kVAh]" (gHomeManager)
Number Tot_cosPhi "cosinus Phi [%.3f]" (gHomeManager)
Number Tot_Freq "Frequenz [%.3f Hz]" (gHomeManager)
Number L1_pIn_act "L1 Bezogene Wirkleistung [%d W]" (gHomeManager)
Number L1_pIn_rea "L1 Bezogene Blindleistung [%d Var]" (gHomeManager)
Number L1_pIn_app "L1 Bezogene Scheinleistung [%d VA]" (gHomeManager)
Number L1_eIn_act "L1 Bezogene Wirkenergie [%d kWh]" (gHomeManager)
Number L1_eIn_rea "L1 Bezogene Blindenergie [%d kVarh]" (gHomeManager)
Number L1_eIn_app "L1 Bezogene Scheinenergie [%d kVAh]" (gHomeManager)
Number L1_pOut_act "L1 Eingespeiste Wirkleistung [%d W]" (gHomeManager)
Number L1_pOut_rea "L1 Eingespeiste Blindleistung [%d Var]" (gHomeManager)
Number L1_pOut_app "L1 Eingespeiste Scheinleistung [%d VA]" (gHomeManager)
Number L1_eOut_act "L1 Eingespeiste Wirkenergie [%d kWh]" (gHomeManager)
Number L1_eOut_rea "L1 Eingespeiste Blindenergie [%d kVarh]" (gHomeManager)
Number L1_eOut_app "L1 Eingespeiste Scheinenergie [%d kVAh]" (gHomeManager)
Number L1_current "L1 Strom [%.3f A]" (gHomeManager)
Number L1_voltage "L1 Spannung [%.3f V]" (gHomeManager)
Number L1_cosPhi "L1 cosinus Phi [%.3f]" (gHomeManager)
Number L2_pIn_act "L2 Bezogene Wirkleistung [%d W]" (gHomeManager)
Number L2_pIn_rea "L2 Bezogene Blindleistung [%d Var]" (gHomeManager)
Number L2_pIn_app "L2 Bezogene Scheinleistung [%d VA]" (gHomeManager)
Number L2_eIn_act "L2 Bezogene Wirkenergie [%d kWh]" (gHomeManager)
Number L2_eIn_rea "L2 Bezogene Blindenergie [%d kVarh]" (gHomeManager)
Number L2_eIn_app "L2 Bezogene Scheinenergie [%d kVAh]" (gHomeManager)
Number L2_pOut_act "L2 Eingespeiste Wirkleistung [%d W]" (gHomeManager)
Number L2_pOut_rea "L2 Eingespeiste Blindleistung [%d Var]" (gHomeManager)
Number L2_pOut_app "L2 Eingespeiste Scheinleistung [%d VA]" (gHomeManager)
Number L2_eOut_act "L2 Eingespeiste Wirkenergie [%d kWh]" (gHomeManager)
Number L2_eOut_rea "L2 Eingespeiste Blindenergie [%d kVarh]" (gHomeManager)
Number L2_eOut_app "L2 Eingespeiste Scheinenergie [%d kVAh]" (gHomeManager)
Number L2_current "L2 Strom [%.3f A]" (gHomeManager)
Number L2_voltage "L2 Spannung [%.3f V]" (gHomeManager)
Number L2_cosPhi "L2 cosinus Phi [%.3f]" (gHomeManager)
Number L3_pIn_act "L3 Bezogene Wirkleistung [%d W]" (gHomeManager)
Number L3_pIn_rea "L3 Bezogene Blindleistung [%d Var]" (gHomeManager)
Number L3_pIn_app "L3 Bezogene Scheinleistung [%d VA]" (gHomeManager)
Number L3_eIn_act "L3 Bezogene Wirkenergie [%d kWh]" (gHomeManager)
Number L3_eIn_rea "L3 Bezogene Blindenergie [%d kVarh]" (gHomeManager)
Number L3_eIn_app "L3 Bezogene Scheinenergie [%d kVAh]" (gHomeManager)
Number L3_pOut_act "L3 Eingespeiste Wirkleistung [%d W]" (gHomeManager)
Number L3_pOut_rea "L3 Eingespeiste Blindleistung [%d Var]" (gHomeManager)
Number L3_pOut_app "L3 Eingespeiste Scheinleistung [%d VA]" (gHomeManager)
Number L3_eOut_act "L3 Eingespeiste Wirkenergie [%d kWh]" (gHomeManager)
Number L3_eOut_rea "L3 Eingespeiste Blindenergie [%d kVarh]" (gHomeManager)
Number L3_eOut_app "L3 Eingespeiste Scheinenergie [%d kVAh]" (gHomeManager)
Number L3_current "L3 Strom [%.3f A]" (gHomeManager)
Number L3_voltage "L3 Spannung [%.3f V]" (gHomeManager)
Number L3_cosPhi "L3 cosinus Phi [%.3f]" (gHomeManager)
Note: The item names must be consistent with those from the JSON output to make item updates in a rule easier.
- Rule template
rule "HM20 Query"
when
Time cron "* * * * * ? *" // update data every second
then
val JSONstring = executeCommandLine(Duration.ofMillis(700), "/usr/bin/python3", "/etc/openhab/scripts/sma_emeter.py", "0123456789")
val DataValid = (transform("JSONPATH", "$.DataValid", JSONstring))
val HM20status = (transform("JSONPATH", "$.HM20state", JSONstring))
val HM20detail = (transform("JSONPATH", "$.StateDetail", JSONstring))
// check status of received data
// JSONstring === null: No data received, probably executeCommandLine timeout, set relevant item states to 0
// HM20status == "OFFLINE": script timeout or no sma packet received, set relevant item states to 0
// HM20status == "ONLINE" and DataValid == "INVALID": Wrong packet ID, handled in script, never seen here, DSL timeout may occur
// HM20status == "ONLINE" and DataValid == "VALID": Valid packet, update item states
if ((JSONstring !== null) && (HM20status == "ONLINE") && (DataValid == "VALID"))
{
// refresh all HM items
gHomeManager.members.forEach[i |
{
i.postUpdate(transform("JSONPATH", "$." + i.name, JSONstring))
}]
else
{
logWarn("Photovoltaik.rules::HM20 Abfrage", "No valid data received, HM items set to 0. State: {}, Detail: {}, JSONstring: {} ", HM20status, HM20detail, JSONstring)
// Additional error handling from here
}
Notes:
- Adjust the cron trigger to your needs
- In the rule template the script is stored in /etc/openhab/scripts/. Adjust the path if needed.
- Replace the 0123456789 with the serial number of your HM 2.0
- The template is a simplified version. E.g. it isn’t useful to update the energy items every second. I personally prefer to use two groups ‘gHomeManagerF’ (power, voltage current and power factor items) and ‘gHomeManagerS’ (energy items) together with two item update loops. The ‘gHomeManagerF’ group items are updated every second while the second loop for the ‘gHomeManagerS’ group items is executed depending on a counter variable which is incremented every second up to 300 (5 min).