Skip to content

Instantly share code, notes, and snippets.

@smashnet
Last active October 7, 2024 07:34
Show Gist options
  • Save smashnet/82ad0b9d7f0ba2e5098e6649ba08f88a to your computer and use it in GitHub Desktop.
Save smashnet/82ad0b9d7f0ba2e5098e6649ba08f88a to your computer and use it in GitHub Desktop.
Library to get a lot of useful data out of Senec appliances.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Library to get a lot of useful data out of Senec appliances.
Tested with: SENEC.Home V3 hybrid duo
Kudos:
* SYSTEM_STATE_NAME taken from https://github.com/mchwalisz/pysenec
"""
import requests
import struct
import logging
import json
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
__author__ = "Nicolas Inden"
__copyright__ = "Copyright 2023, Nicolas Inden"
__credits__ = ["Nicolas Inden", "Mikołaj Chwalisz"]
__license__ = "Apache-2.0 License"
__version__ = "1.0.4"
__maintainer__ = "Nicolas Inden"
__email__ = "[email protected]"
__status__ = "Production"
logging.basicConfig(format='%(asctime)s %(levelname)s:%(message)s',level=logging.INFO)
log = logging.getLogger("Senec")
class Senec():
def __init__(self, device_ip):
self.device_ip = device_ip
self.read_api = f"https://{device_ip}/lala.cgi"
def get_values(self, request_json = {}):
if not request_json: request_json = BASIC_REQUEST
try:
response = requests.post(self.read_api, json=request_json, verify=False)
if response.status_code == 200:
return self.__decode_data(response.json())
#return self.__substitute_system_state(res)
else:
log.warning(f"Status code {response.status_code}")
return {"error": f"Status code {response.status_code}"}
except requests.Timeout:
errmsg = f"{self.device_ip}: Timeout while accessing Senec box."
log.warning(errmsg)
return {"error": errmsg}
except requests.ConnectionError:
errmsg = f"{self.device_ip}: Connection error while accessing Senec box."
log.warning(errmsg)
return {"error": errmsg}
except requests.exceptions.JSONDecodeError as e:
errmsg = f"{self.device_ip}: Could not decode JSON response: {e}"
log.warning(errmsg)
return {"error": errmsg}
except Exception as e:
errmsg = f"{self.device_ip}: Other exception: {e}"
log.warning(errmsg)
return {"error": errmsg}
def get_all_values(self):
request_json = {"STATISTIC": {},"ENERGY": {},"FEATURES": {},"LOG": {},"SYS_UPDATE": {},"WIZARD": {},"BMS": {},"BAT1": {},"BAT1OBJ1": {},"BAT1OBJ2": {},"BAT1OBJ3": {},"BAT1OBJ4": {},"PWR_UNIT": {},"PV1": {},"FACTORY": {},"GRIDCONFIG": {}}
return self.get_values(request_json)
def __decode_data(self, data):
return { k: self.__decode_data_helper(v) for k, v in data.items() }
def __decode_data_helper(self, data):
if isinstance(data, str):
return self.__decode_value(data)
if isinstance(data, list):
return [self.__decode_value(val) for val in data]
if isinstance(data, dict):
return { k: self.__decode_data_helper(v) for k, v in data.items() }
def __decode_value(self, value):
if value.startswith("fl_"):
return struct.unpack('!f', bytes.fromhex(value[3:]))[0]
if value.startswith("u8_"):
return struct.unpack('!B', bytes.fromhex(value[3:]))[0]
if value.startswith("i3_") or value.startswith("i8_") or value.startswith("u3_") or value.startswith("u1_"):
return int(value[3:], 16)
if value.startswith("st_"):
return value[3:]
return value
def __substitute_system_state(self, data):
#currently unused
system_state = data['STATISTIC']['CURRENT_STATE']
if system_state == "VARIABLE_NOT_FOUND":
return data
try:
data['STATISTIC']['CURRENT_STATE'] = SYSTEM_STATE_NAME[system_state]
except KeyError as e:
log.error(f"Failed substituting system state: {e}")
data['STATISTIC']['CURRENT_STATE'] = f"unknown ({system_state})"
finally:
return data
BASIC_REQUEST = {
#'STATISTIC': {
# 'CURRENT_STATE': '', # Current state of the system (int, see SYSTEM_STATE_NAME)
# 'LIVE_BAT_CHARGE_MASTER': '', # Battery charge amount since installation (kWh)
# 'LIVE_BAT_DISCHARGE_MASTER': '', # Battery discharge amount since installation (kWh)
# 'LIVE_GRID_EXPORT': '', # Grid export amount since installation (kWh)
# 'LIVE_GRID_IMPORT': '', # Grid import amount since installation (kWh)
# 'LIVE_HOUSE_CONS': '', # House consumption since installation (kWh)
# 'LIVE_PV_GEN': '', # PV generated power since installation (kWh)
# 'MEASURE_TIME': '' # Unix timestamp for above values (ms)
#},
'ENERGY': {
'GUI_BAT_DATA_CURRENT': '', # Battery charge current: negative if discharging, positiv if charging (A)
'GUI_BAT_DATA_FUEL_CHARGE': '', # Remaining battery (percent)
'GUI_BAT_DATA_POWER': '', # Battery charge power: negative if discharging, positiv if charging (W)
'GUI_BAT_DATA_VOLTAGE': '', # Battery voltage (V)
'GUI_GRID_POW': '', # Grid power: negative if exporting, positiv if importing (W)
'GUI_HOUSE_POW': '', # House power consumption (W)
'GUI_INVERTER_POWER': '', # PV production (W)
'STAT_HOURS_OF_OPERATION': '' # Appliance hours of operation
},
'BMS': {
'CHARGED_ENERGY': '', # List: Charged energy per battery
'DISCHARGED_ENERGY': '', # List: Discharged energy per battery
'CYCLES': '' # List: Cycles per battery
},
'PV1': {
'MPP_CUR': '', # List: MPP current (A)
'MPP_POWER': '', # List: MPP power (W)
'MPP_VOL': '', # List: MPP voltage (V)
'POWER_RATIO': '', # Grid export limit (percent)
'P_TOTAL': '' # ?
},
'FACTORY': {
'DESIGN_CAPACITY': '', # Battery design capacity (Wh)
'MAX_CHARGE_POWER_DC': '', # Battery max charging power (W)
'MAX_DISCHARGE_POWER_DC': '' # Battery max discharging power (W)
}
}
SYSTEM_STATE_NAME = {
0: "INITIAL STATE",
1: "ERROR INVERTER COMMUNICATION",
2: "ERROR ELECTRICY METER",
3: "RIPPLE CONTROL RECEIVER",
4: "INITIAL CHARGE",
5: "MAINTENANCE CHARGE",
6: "MAINTENANCE READY",
7: "MAINTENANCE REQUIRED",
8: "MAN. SAFETY CHARGE",
9: "SAFETY CHARGE READY",
10: "FULL CHARGE",
11: "EQUALIZATION: CHARGE",
12: "DESULFATATION: CHARGE",
13: "BATTERY FULL",
14: "CHARGE",
15: "BATTERY EMPTY",
16: "DISCHARGE",
17: "PV + DISCHARGE",
18: "GRID + DISCHARGE",
19: "PASSIVE",
20: "OFF",
21: "OWN CONSUMPTION",
22: "RESTART",
23: "MAN. EQUALIZATION: CHARGE",
24: "MAN. DESULFATATION: CHARGE",
25: "SAFETY CHARGE",
26: "BATTERY PROTECTION MODE",
27: "EG ERROR",
28: "EG CHARGE",
29: "EG DISCHARGE",
30: "EG PASSIVE",
31: "EG PROHIBIT CHARGE",
32: "EG PROHIBIT DISCHARGE",
33: "EMERGANCY CHARGE",
34: "SOFTWARE UPDATE",
35: "NSP ERROR",
36: "NSP ERROR: GRID",
37: "NSP ERROR: HARDWRE",
38: "NO SERVER CONNECTION",
39: "BMS ERROR",
40: "MAINTENANCE: FILTER",
41: "SLEEPING MODE",
42: "WAITING EXCESS",
43: "CAPACITY TEST: CHARGE",
44: "CAPACITY TEST: DISCHARGE",
45: "MAN. DESULFATATION: WAIT",
46: "MAN. DESULFATATION: READY",
47: "MAN. DESULFATATION: ERROR",
48: "EQUALIZATION: WAIT",
49: "EMERGANCY CHARGE: ERROR",
50: "MAN. EQUALIZATION: WAIT",
51: "MAN. EQUALIZATION: ERROR",
52: "MAN: EQUALIZATION: READY",
53: "AUTO. DESULFATATION: WAIT",
54: "ABSORPTION PHASE",
55: "DC-SWITCH OFF",
56: "PEAK-SHAVING: WAIT",
57: "ERROR BATTERY INVERTER",
58: "NPU-ERROR",
59: "BMS OFFLINE",
60: "MAINTENANCE CHARGE ERROR",
61: "MAN. SAFETY CHARGE ERROR",
62: "SAFETY CHARGE ERROR",
63: "NO CONNECTION TO MASTER",
64: "LITHIUM SAFE MODE ACTIVE",
65: "LITHIUM SAFE MODE DONE",
66: "BATTERY VOLTAGE ERROR",
67: "BMS DC SWITCHED OFF",
68: "GRID INITIALIZATION",
69: "GRID STABILIZATION",
70: "REMOTE SHUTDOWN",
71: "OFFPEAK-CHARGE",
72: "ERROR HALFBRIDGE",
73: "BMS: ERROR OPERATING TEMPERATURE",
74: "FACOTRY SETTINGS NOT FOUND",
75: "BACKUP POWER MODE - ACTIVE",
76: "BACKUP POWER MODE - BATTERY EMPTY",
77: "BACKUP POWER MODE ERROR",
78: "INITIALISING",
79: "INSTALLATION MODE",
80: "GRID OFFLINE",
81: "BMS UPDATE NEEDED",
82: "BMS CONFIGURATION NEEDED",
83: "INSULATION TEST",
84: "SELFTEST",
85: "EXTERNAL CONTROL",
86: "ERROR: TEMPERATURESENSOR",
87: "GRID OPERATOR: CHARGE PROHIBITED",
88: "GRID OPERATOR: DISCHARGE PROHIBITED",
89: "SPARE CAPACITY",
90: "SELFTEST ERROR",
91: "EARTH FAULT",
92: "PV-MODE",
93: "REMOTE DISCONNECTION",
94: "ERROR DRM0",
95: "BATTERY DIAGNOSIS"
}
if __name__ == "__main__":
api = Senec("IP_OF_YOUR_SENEC_APPLIANCE")
print(api.get_values())
@mikesheen
Copy link

Is that duplicate of BAT1OBJ2 in request_json intentional? Doesn't seem impact anything, but looks redundant.

@smashnet
Copy link
Author

Hi, i used your code here https://github.com/Hobbyflyer/senec_monitoring?fbclid=IwAR0M8O4uSQU_D8rc3BlnNGvXKUCEnsg35lNrw3gdwCu6ia0L5uuLQLbgMEM
Hope it ok...

Yes of course :)

Hope it works well for you!

@jolerche
Copy link

jolerche commented Feb 3, 2023

Hi Nicolas,
Thanks a lot for your work. I have a question though:
Is it intended to create JSON output? If so, JSON Lint at https://jsonlint.com/ qualified output as "invalid" as according to JSON syntax (https://www.json.org/json-en.html) keys must be enclosed in double instead of single quotes.

Importing json at the beginning

import json

and replacing the end of script

if __name__ == "__main__":
    api = Senec("THE_IP_ADDRESS")
    s = json.dumps( api.get_values() )
    print (s)

created valid JSON according to above linter. If desired, your code certainly might offer better ways of mentioned transformation.

What do you think?

Greetings,
Jörg

@smashnet
Copy link
Author

Hi Jörg,

sorry for the late answer.

The __main__ part of the file is just for demonstration purposes. I assume this file to be used within other projects where you would just instantiate Senec and use get_values() to further process the data. In such a case a Python dict is the right thing to return.

Greetings,
Nico

@jolerche
Copy link

Hi Nico,

thanks for your feedback - I totally agree!

Greetings,
Jörg

@changyang1230
Copy link

Used your code for smart charger integration script. Thanks for the useful library! (New to Python, literally just coded everything with the help of ChatGPT over the last day :P)

@phorbi
Copy link

phorbi commented Aug 18, 2023

With the latest update (REVISION MCU: 3825; REVISION MCU-BL: 2307; REVISION NPU-REGS: 10; REVISION NPU-IMAGE: 2307; REVISION GUI: 2966) a few things seem to have happened:

  1. the access to http was switched off, only https works --> line 33 should be changed to:
    self.read_api = f"https://{device_ip}/lala.cgi"
  2. the ssl certificate is not verifiable --> I hope SENEC fixes this. for now as a work around line 38 can be changed to:
    response = requests.post(self.read_api, json=request_json, verify=False)
  3. I noticed that all "'STATISTIC': " Objects are no longer available

@changyang1230
Copy link

Hi, it appears that some other projects that read SENEC's data has managed to find a workaround for the changes in SENEC's firmware that now mandates https.

Example here: https://github.com/evcc-io/evcc/blob/8351a4471f74d8811e4bdb02e75c5a7b6d0ff8bb/templates/definition/meter/senec-home.yaml#L2

Would be wonderful if this amazing library can be updated to incorporate this fix too.

@smashnet
Copy link
Author

Thx @phorbi and @changyang1230

Did a little patch release to make this work again. Now uses HTTPS to connect to Senec appliance and ignores STATISTICS values.

@changyang1230
Copy link

Thank you so so much!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment