From a2a3a221acc6f303fe4797893b79704e9d65892e Mon Sep 17 00:00:00 2001 From: fboundy Date: Thu, 14 Nov 2024 01:59:19 +0000 Subject: [PATCH] Add full support for SolisCloud --- .test/solis_cloud_test.py | 227 +++++++++++++++ README.md | 125 +-------- apps/pv_opt/config/config.yaml | 27 +- apps/pv_opt/pv_opt.py | 6 +- apps/pv_opt/solis.py | 491 ++++++++++++++++++++++++--------- 5 files changed, 596 insertions(+), 280 deletions(-) create mode 100644 .test/solis_cloud_test.py diff --git a/.test/solis_cloud_test.py b/.test/solis_cloud_test.py new file mode 100644 index 0000000..2146a40 --- /dev/null +++ b/.test/solis_cloud_test.py @@ -0,0 +1,227 @@ +# %% +import hashlib +import hmac +import base64 +import json +import re +import requests +from http import HTTPStatus +from datetime import datetime, timezone +import pandas as pd + +# def getInverterList(config): +# body = getBody(stationId=config['plantId']) +# print(body) +# body = '{"stationId":"'+config['plantId']+'"}' +# print(body) +# header = prepare_header(config, body, INVERTER_URL) +# response = requests.post("https://www.soliscloud.com:13333"+INVERTER_URL, data = body, headers = header) +# inverterList = response.json() +# inverterId = "" +# for record in inverterList['data']['page']['records']: +# inverterId = record.get('id') +# return inverterList['data']['page']['records'][0] +INVERTER_DEFS = { + "SOLIS_CLOUD": { + "bits": [ + "SelfUse", + "Timed", + "OffGrid", + "BatteryWake", + "Backup", + "GridCharge", + "FeedInPriority", + ], + }, +} + + +class SolisCloud: + URLS = { + "root": "https://www.soliscloud.com:13333", + "login": "/v2/api/login", + "control": "/v2/api/control", + "inverterList": "/v1/api/inverterList", + "inverterDetail": "/v1/api/inverterDetail", + "atRead": "/v2/api/atRead", + } + + def __init__(self, username, password, key_id, key_secret, plant_id): + self.username = username + self.key_id = key_id + self.key_secret = key_secret + self.plant_id = plant_id + self.md5passowrd = hashlib.md5(password.encode("utf-8")).hexdigest() + self.token = "" + + def get_body(self, **params): + body = "{" + for key in params: + body += f'"{key}":"{params[key]}",' + body = body[:-1] + "}" + return body + + def digest(self, body: str) -> str: + return base64.b64encode(hashlib.md5(body.encode("utf-8")).digest()).decode("utf-8") + + def header(self, body: str, canonicalized_resource: str) -> dict[str, str]: + content_md5 = self.digest(body) + content_type = "application/json" + + now = datetime.now(timezone.utc) + date = now.strftime("%a, %d %b %Y %H:%M:%S GMT") + + encrypt_str = "POST" + "\n" + content_md5 + "\n" + content_type + "\n" + date + "\n" + canonicalized_resource + hmac_obj = hmac.new(self.key_secret.encode("utf-8"), msg=encrypt_str.encode("utf-8"), digestmod=hashlib.sha1) + sign = base64.b64encode(hmac_obj.digest()) + authorization = "API " + self.key_id + ":" + sign.decode("utf-8") + + header = { + "Content-MD5": content_md5, + "Content-Type": content_type, + "Date": date, + "Authorization": authorization, + } + return header + + @property + def inverter_id(self): + body = self.get_body(stationId=self.plant_id) + header = self.header(body, self.URLS["inverterList"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterList"], data=body, headers=header) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["page"]["records"][0].get("id", "") + + @property + def inverter_sn(self): + body = self.get_body(stationId=self.plant_id) + header = self.header(body, self.URLS["inverterList"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterList"], data=body, headers=header) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["page"]["records"][0].get("sn", "") + + @property + def inverter_details(self): + body = self.get_body(id=self.inverter_id, sn=self.inverter_sn) + header = self.header(body, self.URLS["inverterDetail"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterDetail"], data=body, headers=header) + + if response.status_code == HTTPStatus.OK: + return response.json()["data"] + + @property + def is_online(self): + return self.inverter_details["state"] == 1 + + @property + def last_seen(self): + return pd.to_datetime(int(self.inverter_details["dataTimestamp"]), unit="ms") + + def set_code(self, cid, value): + if self.token == "": + self.login() + + if self.token != "": + body = self.get_body(inverterSn=self.inverter_sn, cid=cid, value=value) + headers = self.header(body, self.URLS["control"]) + headers["token"] = self.token + response = requests.post(self.URLS["root"] + self.URLS["control"], data=body, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json() + + def read_code(self, cid): + if self.token == "": + self.login() + + if self.token != "": + body = self.get_body(inverterSn=self.inverter_sn, cid=cid) + headers = self.header(body, self.URLS["atRead"]) + headers["token"] = self.token + response = requests.post(self.URLS["root"] + self.URLS["atRead"], data=body, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["msg"] + + def login(self): + body = self.get_body(username=self.username, password=self.md5passowrd) + header = self.header(body, self.URLS["login"]) + response = requests.post(self.URLS["root"] + self.URLS["login"], data=body, headers=header) + status = response.status_code + if status == HTTPStatus.OK: + result = response.json() + self.token = result["csrfToken"] + print("Logged in to SolisCloud OK") + + else: + print(status) + + def mode_switch(self): + bits = INVERTER_DEFS["SOLIS_CLOUD"]["bits"] + code = int(self.read_code("636")) + switches = {bit: (code & 2**i == 2**i) for i, bit in enumerate(bits)} + return {"code": code, "switches": switches} + + def timed_status(self, tz="GB"): + data = self.read_code("103").split(",") + return { + "charge": { + "current": float(data[0]), + "start": pd.Timestamp(data[2].split("-")[0], tz=tz), + "end": pd.Timestamp(data[2].split("-")[1], tz=tz), + }, + "discharge": { + "current": float(data[1]), + "start": pd.Timestamp(data[3].split("-")[0], tz=tz), + "end": pd.Timestamp(data[3].split("-")[1], tz=tz), + }, + } + + def read_backup_mode_soc(self): + return int(self.read_code("157")) + + def set_mode_switch(self, code): + return self.set_code("636", code) + + def get_time_string(self, time_status): + time_string = ",".join( + [ + str(int(time_status["charge"]["current"])), + str(int(time_status["discharge"]["current"])), + f'{time_status["charge"]["start"].strftime("%H:%M")}-{time_status["charge"]["end"].strftime("%H:%M")}', + f'{time_status["discharge"]["start"].strftime("%H:%M")}-{time_status["discharge"]["end"].strftime("%H:%M")}', + ] + ) + return f"{time_string},0,0,00:00-00:00,00:00-00:00,0,0,00:00-00:00,00:00-00:00" + + def set_timer(self, direction, start, end, power): + voltage = 50 + current_times = self.timed_status() + new_times = current_times.copy() + new_times[direction]["start"] = start + new_times[direction]["end"] = end + new_times[direction]["current"] = power / voltage + current_time_string = self.read_code(103) + new_time_string = self.get_time_string(new_times) + if new_time_string != current_time_string: + return self.set_code("103", new_time_string) + else: + return {"code": -1} + + +# %% +if __name__ == "__main__": + config = { + "key_secret": "735f96b6131b4691af944de80d2f1a1f", + "key_id": "1300386381676670076", + "plant_id": "1298491919448891215", + "username": "boundywindsor@gmail.com", + "password": "7y@-Ekdh&@F9", + } + + sc = SolisCloud(**config) + sc.login() + print(sc.mode_switch()) + print(sc.timed_status()) + +# %% +sc.set_timer("charge", pd.Timestamp("00:50"), pd.Timestamp("01:00"), 3000) +# %% diff --git a/README.md b/README.md index 7466d85..ebe1409 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v3.17.2 +# PV Opt: Home Assistant Solar/Battery Optimiser v3.18.0

This documentation needs updating!

@@ -9,8 +9,7 @@ The application will integrate fully with Solis inverters which are controlled u - [Home Assistant Solax Modbus Integration](https://github.com/wills106/homeassistant-solax-modbus) - [Home Assistant Core Modbus Integration](https://github.com/fboundy/ha_solis_modbus) - [Home Assistant Solarman Integration](https://github.com/StephanJoubert/home_assistant_solarman) -- [Home Assistant Solis Sensor Integration](https://github.com/hultenvp/solis-sensor) (read-only mode) -- [Home Assistant Solis Control Integration](https://github.com/stevegal/solis_control) (allows inverter control via solis_cloud and HA automations) +- [Home Assistant Solis Sensor Integration](https://github.com/hultenvp/solis-sensor) Once installed it should require miminal configuration. Other inverters/integrations can be added if required or can be controlled indirectly using automations. @@ -324,126 +323,6 @@ Restarts between Home Assistant and Add-Ons are not synchronised so it is helpfu addon: a0d7b954_appdaemon mode: single -

14. For Solis-Control: Add Automation to Control Inverter

- -If you're using the solis-sensor and solis_control integrations through Solis Cloud, you'll need to add the following automation which will send the messages to Solis Cloud in order to control your inverter. N.B: It's important that you've set up the solis_control integration correctly and requested API access via Solis Cloud Technical Support. - -``` -alias: "Solis: Use PV_Opt" -description: "Use the output of pv_opt to control your Solis inverter via Solis Cloud." -trigger: - - platform: state - entity_id: - - sensor.pvopt_status - to: Idle (Read Only) - for: - hours: 0 - minutes: 0 - seconds: 5 - enabled: false - - platform: time_pattern - hours: /1 - minutes: "00" - seconds: "05" - - platform: time_pattern - hours: /1 - minutes: "30" - seconds: "05" - - platform: state - entity_id: - - sensor.pvopt_charge_start -condition: [] -action: - - if: - - condition: template - value_template: >- - {{ states('sensor.pvopt_charge_start') | as_datetime | as_local <= - today_at("23:59") }} - then: - - data: - days: - - chargeCurrent: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set chargeAmps = min((max(direction, 0.0) | - round(method='floor')), 50)%} {{ chargeAmps }} - dischargeCurrent: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set dischargeAmps = min((min(direction, 0.0) | abs | - round(method='floor')), 50) %} {{ dischargeAmps }} - chargeStartTime: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set startChargeTime = '00:00' %} {% if direction >= - 0.0 -%} - {% set startChargeTime = (as_local(as_datetime(states('sensor.pvopt_charge_start')))|string)[11:16] %} - {%- endif %} {{ startChargeTime }} - chargeEndTime: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set endChargeTime = '00:00' %} {% if direction >= 0.0 - -%} - {% set endChargeTime = (as_local(as_datetime(states('sensor.pvopt_charge_end')))|string)[11:16] %} - {%- endif %} {{ endChargeTime }} - dischargeStartTime: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set startDischargeTime = '00:00' %} {% if direction < - 0.0 -%} - {% set startDischargeTime = (as_local(as_datetime(states('sensor.pvopt_charge_start')))|string)[11:16] %} - {%- endif %} {{ startDischargeTime }} - dischargeEndTime: >- - {% set direction = float(states('sensor.pvopt_charge_current'), - 0.0) %} {% set endDischargeTime = '00:00' %} {% if direction < - 0.0 -%} - {% set endDischargeTime = (as_local(as_datetime(states('sensor.pvopt_charge_end')))|string)[11:16] %} - {%- endif %} {{ endDischargeTime }} - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - config: - secret: <> - key_id: "<>" - username: <> - password: <> - plantId: "<>" - action: pyscript.solis_control - else: - - data: - days: - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - - chargeCurrent: "0" - dischargeCurrent: "0" - chargeStartTime: "00:00" - chargeEndTime: "00:00" - dischargeStartTime: "00:00" - dischargeEndTime: "00:00" - config: - secret: <> - key_id: "<>" - username: <> - password: <> - plantId: "<>" - action: pyscript.solis_control -mode: single -``` -

Configuration

diff --git a/apps/pv_opt/config/config.yaml b/apps/pv_opt/config/config.yaml index 23d6b51..e7276c6 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -318,39 +318,26 @@ pv_opt: inverter_type: SOLIS_CLOUD device_name: soliscloud + + soliscloud_username: !secret soliscloud_username + soliscloud_password: !secret soliscloud_password + soliscloud_key_id: !secret soliscloud_key_id + soliscloud_key_secret: !secret soliscloud_key_secret + soliscloud_plant_id: !secret soliscloud_plant_id battery_voltage: sensor.{device_name}_battery_voltage - # update_cycle_seconds: 15 + update_cycle_seconds: 0 maximum_dod_percent: sensor.{device_name}_force_discharge_soc id_consumption_today: sensor.{device_name}_daily_grid_energy_used # id_consumption: # - sensor.{device_name}_total_consumption_power - # - sensor.{device_name}_backup_load_power id_grid_import_today: sensor.{device_name}_daily_grid_energy_purchased id_grid_export_today: sensor.{device_name}_daily_on_grid_energy id_battery_soc: sensor.{device_name}_remaining_battery_capacity - # id_grid_import_today: sensor.{device_name}_grid_import_today - # id_grid_export_today: sensor.{device_name}_grid_export_today - - # id_battery_soc: sensor.{device_name}_battery_soc - # id_timed_charge_start_hours: number.{device_name}_timed_charge_start_hours - # id_timed_charge_start_minutes: number.{device_name}_timed_charge_start_minutes - # id_timed_charge_end_hours: number.{device_name}_timed_charge_end_hours - # id_timed_charge_end_minutes: number.{device_name}_timed_charge_end_minutes - # id_timed_charge_current: number.{device_name}_timed_charge_current - - # id_timed_discharge_start_hours: number.{device_name}_timed_discharge_start_hours - # id_timed_discharge_start_minutes: number.{device_name}_timed_discharge_start_minutes - # id_timed_discharge_end_hours: number.{device_name}_timed_discharge_end_hours - # id_timed_discharge_end_minutes: number.{device_name}_timed_discharge_end_minutes - # id_timed_discharge_current: number.{device_name}_timed_discharge_current - - # id_timed_charge_discharge_button: button.{device_name}_update_charge_discharge_times - # id_inverter_mode: select.{device_name}_energy_storage_control_switch # Tariff comparison # id_daily_solar: sensor.{device_name}_power_generation_today diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 160b1f2..640201e 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -12,7 +12,7 @@ from numpy import nan import re -VERSION = "3.17.2" +VERSION = "3.18.0" OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" @@ -459,6 +459,8 @@ class PVOpt(hass.Hass): @ad.app_lock def initialize(self): self.config = {} + self.change_items = {} + self.config_state = {} self.log("") self.log(f"******************* PV Opt v{VERSION} *******************") self.log("") @@ -509,8 +511,6 @@ def initialize(self): if self.debug or self.args.get("list_entities", True): self._list_entities() - self.change_items = {} - self.config_state = {} self.timer_handle = None self.handles = {} self.mqtt_handles = {} diff --git a/apps/pv_opt/solis.py b/apps/pv_opt/solis.py index db7fd51..cbba11f 100644 --- a/apps/pv_opt/solis.py +++ b/apps/pv_opt/solis.py @@ -1,5 +1,22 @@ import pandas as pd import time +import hashlib +import hmac +import base64 +import json +import re +import requests +from http import HTTPStatus +from datetime import datetime, timezone + +URLS = { + "root": "https://www.soliscloud.com:13333", + "login": "/v2/api/login", + "control": "/v2/api/control", + "inverterList": "/v1/api/inverterList", + "atRead": "/v2/api/atRead", +} + TIMEFORMAT = "%H:%M" INVERTER_DEFS = { @@ -205,16 +222,24 @@ }, }, "SOLIS_CLOUD": { - "online": "sensor.{device_name}_temperature", + "bits": [ + "SelfUse", + "Timed", + "OffGrid", + "BatteryWake", + "Backup", + "GridCharge", + "FeedInPriority", + ], "default_config": { "maximum_dod_percent": "sensor.{device_name}_force_discharge_soc", "id_consumption_today": "sensor.{device_name}_daily_grid_energy_used", "id_grid_import_today": "sensor.{device_name}_daily_grid_energy_purchased", "id_grid_export_today": "sensor.{device_name}_daily_on_grid_energy", "id_battery_soc": "sensor.{device_name}_remaining_battery_capacity", - "supports_hold_soc": False, + "supports_hold_soc": True, "supports_forced_discharge": True, - "update_cycle_seconds": 300, + "update_cycle_seconds": 0, }, "brand_config": { "battery_voltage": "sensor.{device_name}_battery_voltage", @@ -223,6 +248,177 @@ } +class SolisCloud: + URLS = { + "root": "https://www.soliscloud.com:13333", + "login": "/v2/api/login", + "control": "/v2/api/control", + "inverterList": "/v1/api/inverterList", + "inverterDetail": "/v1/api/inverterDetail", + "atRead": "/v2/api/atRead", + } + + def __init__(self, username, password, key_id, key_secret, plant_id, **kwargs): + self.username = username + self.key_id = key_id + self.key_secret = key_secret + self.plant_id = plant_id + self.md5password = hashlib.md5(password.encode("utf-8")).hexdigest() + self.token = "" + self.log = kwargs.get("log", print) + + def get_body(self, **params): + body = "{" + for key in params: + body += f'"{key}":"{params[key]}",' + body = body[:-1] + "}" + return body + + def digest(self, body: str) -> str: + return base64.b64encode(hashlib.md5(body.encode("utf-8")).digest()).decode("utf-8") + + def header(self, body: str, canonicalized_resource: str) -> dict[str, str]: + content_md5 = self.digest(body) + content_type = "application/json" + + now = datetime.now(timezone.utc) + date = now.strftime("%a, %d %b %Y %H:%M:%S GMT") + + encrypt_str = "POST" + "\n" + content_md5 + "\n" + content_type + "\n" + date + "\n" + canonicalized_resource + hmac_obj = hmac.new(self.key_secret.encode("utf-8"), msg=encrypt_str.encode("utf-8"), digestmod=hashlib.sha1) + sign = base64.b64encode(hmac_obj.digest()) + authorization = "API " + str(self.key_id) + ":" + sign.decode("utf-8") + + header = { + "Content-MD5": content_md5, + "Content-Type": content_type, + "Date": date, + "Authorization": authorization, + } + return header + + @property + def inverter_id(self): + body = self.get_body(stationId=self.plant_id) + header = self.header(body, self.URLS["inverterList"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterList"], data=body, headers=header) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["page"]["records"][0].get("id", "") + + @property + def inverter_sn(self): + body = self.get_body(stationId=self.plant_id) + header = self.header(body, self.URLS["inverterList"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterList"], data=body, headers=header) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["page"]["records"][0].get("sn", "") + + @property + def inverter_details(self): + body = self.get_body(id=self.inverter_id, sn=self.inverter_sn) + header = self.header(body, self.URLS["inverterDetail"]) + response = requests.post(self.URLS["root"] + self.URLS["inverterDetail"], data=body, headers=header) + + if response.status_code == HTTPStatus.OK: + return response.json()["data"] + + @property + def is_online(self): + return self.inverter_details["state"] == 1 + + @property + def last_seen(self): + return pd.to_datetime(int(self.inverter_details["dataTimestamp"]), unit="ms") + + def read_code(self, cid): + if self.token == "": + self.login() + body = self.get_body(inverterSn=self.inverter_sn, cid=cid) + headers = self.header(body, self.URLS["atRead"]) + headers["token"] = self.token + response = requests.post(self.URLS["root"] + self.URLS["atRead"], data=body, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json()["data"]["msg"] + + def set_code(self, cid, value): + if self.token == "": + self.login() + + if self.token != "": + body = self.get_body(inverterSn=self.inverter_sn, cid=cid, value=value) + headers = self.header(body, self.URLS["control"]) + headers["token"] = self.token + response = requests.post(self.URLS["root"] + self.URLS["control"], data=body, headers=headers) + if response.status_code == HTTPStatus.OK: + return response.json() + + def login(self): + body = self.get_body(username=self.username, password=self.md5password) + header = self.header(body, self.URLS["login"]) + response = requests.post(self.URLS["root"] + self.URLS["login"], data=body, headers=header) + status = response.status_code + if status == HTTPStatus.OK: + result = response.json() + self.token = result["csrfToken"] + print("Logged in to SolisCloud OK") + + else: + print(status) + + def read_mode_switch(self): + bits = INVERTER_DEFS["SOLIS_CLOUD"]["bits"] + code = int(self.read_code("636")) + switches = {bit: (code & 2**i == 2**i) for i, bit in enumerate(bits)} + return {"code": code, "switches": switches} + + def timed_status(self, tz="GB"): + data = self.read_code("103").split(",") + return { + "charge": { + "current": float(data[0]), + "start": pd.Timestamp(data[2].split("-")[0], tz=tz), + "end": pd.Timestamp(data[2].split("-")[1], tz=tz), + }, + "discharge": { + "current": float(data[1]), + "start": pd.Timestamp(data[3].split("-")[0], tz=tz), + "end": pd.Timestamp(data[3].split("-")[1], tz=tz), + }, + } + + def read_backup_mode_soc(self): + return int(self.read_code("157")) + + def set_mode_switch(self, code): + return self.set_code("636", code) + + def get_time_string(self, time_status): + time_string = ",".join( + [ + str(int(time_status["charge"]["current"])), + str(int(time_status["discharge"]["current"])), + f'{time_status["charge"]["start"].strftime("%H:%M")}-{time_status["charge"]["end"].strftime("%H:%M")}', + f'{time_status["discharge"]["start"].strftime("%H:%M")}-{time_status["discharge"]["end"].strftime("%H:%M")}', + ] + ) + return f"{time_string},0,0,00:00-00:00,00:00-00:00,0,0,00:00-00:00,00:00-00:00" + + def set_timer(self, direction, start, end, current): + current_times = self.timed_status() + new_times = current_times.copy() + if start is not None: + new_times[direction]["start"] = start + if end is not None: + new_times[direction]["end"] = end + new_times[direction]["current"] = current + current_time_string = self.read_code(103) + new_time_string = self.get_time_string(new_times) + if new_time_string != current_time_string: + return self.set_code("103", new_time_string) + else: + return {"code": -1} + + class InverterController: def __init__(self, inverter_type, host) -> None: self.host = host @@ -243,20 +439,30 @@ def __init__(self, inverter_type, host) -> None: conf[item] = [z.replace("{device_name}", self.host.device_name) for z in defs[item]] else: conf[item] = defs[item] + if self.type == "SOLIS_CLOUD": + params = { + item: host.args.get(f"soliscloud_{item}") + for item in ["username", "password", "key_id", "key_secret", "plant_id"] + } + if all([x is not None for x in params.values()]): + self.cloud = SolisCloud(**params, log=self.log) + else: + raise Exception("Unable to create Solis Cloud controller") def is_online(self): - entity_id = INVERTER_DEFS[self.type].get("online", (None, None)) - if entity_id is not None: - entity_id = entity_id.replace("{device_name}", self.host.device_name) - return self.host.get_state_retry(entity_id) not in ["unknown", "unavailable"] + if self.type == "SOLIS_CLOUD": + return self.cloud.is_online else: - return True + entity_id = INVERTER_DEFS[self.type].get("online", (None, None)) + if entity_id is not None: + entity_id = entity_id.replace("{device_name}", self.host.device_name) + return self.host.get_state_retry(entity_id) not in ["unknown", "unavailable"] + else: + return True def enable_timed_mode(self): - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CLOUD"]: self._solis_set_mode_switch(SelfUse=True, Timed=True, GridCharge=True, Backup=False) - elif self.type == "SOLIS_CLOUD": - pass else: self._unknown_inverter() @@ -271,7 +477,7 @@ def control_discharge(self, enable, **kwargs): self._control_charge_discharge("discharge", enable, **kwargs) def hold_soc(self, enable, soc=None, **kwargs): - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CLOUD"]: start = kwargs.get("start", pd.Timestamp.now(tz=self.tz).floor("1min")) end = kwargs.get("end", pd.Timestamp.now(tz=self.tz).ceil("30min")) self._solis_control_charge_discharge( @@ -281,8 +487,6 @@ def hold_soc(self, enable, soc=None, **kwargs): end=end, power=0, ) - elif self.type == "SOLIS_CLOUD": - pass else: self._unknown_inverter() @@ -325,9 +529,7 @@ def hold_soc_old(self, enable, soc=None): @property def status(self): status = None - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - status = self._solis_state() - elif self.type == "SOLIS_CLOUD": + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CLOUD"]: status = self._solis_state() return status @@ -335,10 +537,8 @@ def _monitor_target_soc(self, target_soc, mode="charge"): pass def _control_charge_discharge(self, direction, enable, **kwargs): - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CLOUD"]: self._solis_control_charge_discharge(direction, enable, **kwargs) - elif self.type == "SOLIS_CLOUD": - pass def _solis_control_charge_discharge(self, direction, enable, **kwargs): status = self._solis_state() @@ -379,132 +579,146 @@ def _solis_control_charge_discharge(self, direction, enable, **kwargs): write_flag = True value_changed = False - for limit in times: - if times[limit] is not None: - for unit in ["hours", "minutes"]: - entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] - if unit == "hours": - value = times[limit].hour - else: - value = times[limit].minute + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: + for limit in times: + if times[limit] is not None: + for unit in ["hours", "minutes"]: + entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] + if unit == "hours": + value = times[limit].hour + else: + value = times[limit].minute - if self.type == "SOLIS_SOLAX_MODBUS": - changed, written = self.host.write_and_poll_value( - entity_id=entity_id, value=value, verbose=True - ) - elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - changed, written = self._solis_write_time_register(direction, limit, unit, value) + if self.type == "SOLIS_SOLAX_MODBUS": + changed, written = self.host.write_and_poll_value( + entity_id=entity_id, value=value, verbose=True + ) + elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + changed, written = self._solis_write_time_register(direction, limit, unit, value) + + else: + e = "Unknown inverter type" + self.log(e, level="ERROR") + raise Exception(e) + + if changed: + if written: + self.log(f"Wrote {direction} {limit} {unit} of {value} to inverter") + value_changed = True + else: + self.log( + f"Failed to write {direction} {limit} {unit} to inverter", + level="ERROR", + ) + write_flag = False + + if value_changed: + if self.type == "SOLIS_SOLAX_MODBUS" and write_flag: + entity_id = self.host.config["id_timed_charge_discharge_button"] + self.host.call_service("button/press", entity_id=entity_id) + time.sleep(0.5) + try: + time_pressed = pd.Timestamp(self.host.get_state_retry(entity_id)) + + dt = (pd.Timestamp.now(self.host.tz) - time_pressed).total_seconds() + if dt < 10: + self.log(f"Successfully pressed button {entity_id}") - else: - e = "Unknown inverter type" - self.log(e, level="ERROR") - raise Exception(e) - - if changed: - if written: - self.log(f"Wrote {direction} {limit} {unit} of {value} to inverter") - value_changed = True else: self.log( - f"Failed to write {direction} {limit} {unit} to inverter", - level="ERROR", + f"Failed to press button {entity_id}. Last pressed at {time_pressed.strftime(TIMEFORMAT)} ({dt:0.2f} seconds ago)" ) - write_flag = False + except: + self.log(f"Failed to press button {entity_id}: it appears to never have been pressed.") - if value_changed: - if self.type == "SOLIS_SOLAX_MODBUS" and write_flag: - entity_id = self.host.config["id_timed_charge_discharge_button"] - self.host.call_service("button/press", entity_id=entity_id) - time.sleep(0.5) - try: - time_pressed = pd.Timestamp(self.host.get_state_retry(entity_id)) - - dt = (pd.Timestamp.now(self.host.tz) - time_pressed).total_seconds() - if dt < 10: - self.log(f"Successfully pressed button {entity_id}") + else: + self.log("Inverter already at correct time settings") + + if power is not None: + entity_id = self.host.config[f"id_timed_{direction}_current"] + + current = abs(round(power / self.host.get_config("battery_voltage"), 1)) + current = min(current, self.host.get_config("battery_current_limit_amps")) + self.log(f"Power {power:0.0f} = {current:0.1f}A at {self.host.get_config('battery_voltage')}V") + if self.type == "SOLIS_SOLAX_MODBUS": + changed, written = self.host.write_and_poll_value(entity_id=entity_id, value=current, tolerance=1) + elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + changed, written = self._solis_write_current_register(direction, current, tolerance=1) + else: + e = "Unknown inverter type" + self.log(e, level="ERROR") + raise Exception(e) + if changed: + if written: + self.log(f"Current {current}A written to inverter") else: - self.log( - f"Failed to press button {entity_id}. Last pressed at {time_pressed.strftime(TIMEFORMAT)} ({dt:0.2f} seconds ago)" - ) - except: - self.log(f"Failed to press button {entity_id}: it appears to never have been pressed.") - - else: - self.log("Inverter already at correct time settings") - - if power is not None: - entity_id = self.host.config[f"id_timed_{direction}_current"] + self.log(f"Failed to write {current} to inverter") + else: + self.log("Inverter already at correct current") - current = abs(round(power / self.host.get_config("battery_voltage"), 1)) + elif self.type == "SOLIS_CLOUD": + current = abs(round(power / self.host.get_config("battery_voltage"), 0)) current = min(current, self.host.get_config("battery_current_limit_amps")) - self.log(f"Power {power:0.0f} = {current:0.1f}A at {self.host.get_config('battery_voltage')}V") - if self.type == "SOLIS_SOLAX_MODBUS": - changed, written = self.host.write_and_poll_value(entity_id=entity_id, value=current, tolerance=1) - elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - changed, written = self._solis_write_current_register(direction, current, tolerance=1) - else: - e = "Unknown inverter type" - self.log(e, level="ERROR") - raise Exception(e) - - if changed: - if written: - self.log(f"Current {current} written to inverter") - else: - self.log(f"Failed to write {current} to inverter") - else: - self.log("Inverter already at correct current") + self.log(f"Power {power:0.0f} = {current:0.0f}A at {self.host.get_config('battery_voltage')}V") + response = self.cloud.set_timer(direction, times["start"], times["end"], current) + if response["code"] == -1: + self.log("Inverter already at correct time and current settings") + elif response["code"] == 0: + self.log( + f"Wrote {direction} time of {times['start'].strftime('%H:%M')}-{times['end'].strftime('%H:%M')} to inverter" + ) + self.log(f"Current {current}A written to inverter") def _solis_set_mode_switch(self, **kwargs): # Read the mode switch - if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: - if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": - status = self._solis_solax_solarman_mode_switch() + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": + status = self._solis_solax_solarman_mode_switch() - elif self.type == "SOLIS_CORE_MODBUS": - status = self._solis_core_mode_switch() + elif self.type == "SOLIS_CORE_MODBUS": + status = self._solis_core_mode_switch() - switches = status["switches"] - if self.host.debug: - self.log(f">>> kwargs: {kwargs}") - self.log(">>> Solis switch status:") + elif self.type == "SOLIS_CLOUD": + status = self.cloud.read_mode_switch() - for switch in switches: - if switch in kwargs: - if self.host.debug: - self.log(f">>> {switch}: {kwargs[switch]}") - switches[switch] = kwargs[switch] + switches = status["switches"] + if self.host.debug: + self.log(f">>> kwargs: {kwargs}") + self.log(">>> Solis switch status:") - elif self.type == "SOLIS_CLOUD": - pass + for switch in switches: + if switch in kwargs: + if self.host.debug: + self.log(f">>> {switch}: {kwargs[switch]}") + switches[switch] = kwargs[switch] # Set the mode switch - if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: - bits = INVERTER_DEFS[self.type]["bits"] - bin_list = [2**i * switches[bit] for i, bit in enumerate(bits)] - code = sum(bin_list) + bits = INVERTER_DEFS[self.type]["bits"] + bin_list = [2**i * switches[bit] for i, bit in enumerate(bits)] + code = sum(bin_list) + + if self.type != "SOLIS_CLOUD": entity_id = self.host.config["id_inverter_mode"] - if self.type == "SOLIS_SOLAX_MODBUS": - entity_modes = self.host.get_state_retry(entity_id, attribute="options") - modes = {INVERTER_DEFS[self.type]["codes"].get(mode): mode for mode in entity_modes} - # mode = INVERTER_DEFS[self.type]["modes"].get(code) - mode = modes.get(code) - if self.host.debug: - self.log(f">>> Inverter Code: {code}") - self.log(f">>> Entity modes: {entity_modes}") - self.log(f">>> Modes: {modes}") - self.log(f">>> Inverter Mode: {mode}") + if self.type == "SOLIS_SOLAX_MODBUS": + entity_modes = self.host.get_state_retry(entity_id, attribute="options") + modes = {INVERTER_DEFS[self.type]["codes"].get(mode): mode for mode in entity_modes} + # mode = INVERTER_DEFS[self.type]["modes"].get(code) + mode = modes.get(code) + if self.host.debug: + self.log(f">>> Inverter Code: {code}") + self.log(f">>> Entity modes: {entity_modes}") + self.log(f">>> Modes: {modes}") + self.log(f">>> Inverter Mode: {mode}") - self.host.set_select("inverter_mode", mode) + self.host.set_select("inverter_mode", mode) - elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - address = INVERTER_DEFS[self.type]["registers"]["storage_control_switch"] - self._solis_write_holding_register(address=address, value=code, entity_id=entity_id) + elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + address = INVERTER_DEFS[self.type]["registers"]["storage_control_switch"] + self._solis_write_holding_register(address=address, value=code, entity_id=entity_id) elif self.type == "SOLIS_CLOUD": - pass + self.cloud.set_mode_switch(code) def _solis_solax_solarman_mode_switch(self): inverter_mode = self.host.get_state_retry(entity_id=self.host.config["id_inverter_mode"]) @@ -529,28 +743,35 @@ def _solis_core_mode_switch(self): def _solis_state(self): limits = ["start", "end"] + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_SOLARMAN": status = self._solis_solax_solarman_mode_switch() elif self.type == "SOLIS_CORE_MODBUS": status = self._solis_core_mode_switch() - else: - status = {} + elif self.type == "SOLIS_CLOUD": + status = self.cloud.read_mode_switch() + + if self.type in ["SOLIS_SOLAX_MODBUS", "SOLIS_SOLARMAN", "SOLIS_CORE_MODBUS"]: + for direction in ["charge", "discharge"]: + status[direction] = {} + for limit in limits: + states = {} + for unit in ["hours", "minutes"]: + entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] + states[unit] = int(float(self.host.get_state_retry(entity_id=entity_id))) + status[direction][limit] = pd.Timestamp( + f"{states['hours']:02d}:{states['minutes']:02d}", tz=self.host.tz + ) - for direction in ["charge", "discharge"]: - status[direction] = {} - for limit in limits: - states = {} - for unit in ["hours", "minutes"]: - entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] - states[unit] = int(float(self.host.get_state_retry(entity_id=entity_id))) - status[direction][limit] = pd.Timestamp( - f"{states['hours']:02d}:{states['minutes']:02d}", tz=self.host.tz - ) - time_now = pd.Timestamp.now(tz=self.tz) status[direction]["current"] = float( self.host.get_state_retry(self.host.config[f"id_timed_{direction}_current"]) ) + elif self.type == "SOLIS_CLOUD": + status = status | self.cloud.timed_status(tz=self.host.tz) + + time_now = pd.Timestamp.now(tz=self.tz) + for direction in ["charge", "discharge"]: status[direction]["active"] = ( time_now >= status[direction]["start"] and time_now < status[direction]["end"] @@ -562,6 +783,8 @@ def _solis_state(self): status["hold_soc"] = {"active": status["switches"]["Backup"]} if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS": status["hold_soc"]["soc"] = self.host.get_config("id_backup_mode_soc") + elif self.type == "SOLIS_CLOUD": + status["hold_soc"]["soc"] = self.cloud.read_backup_mode_soc() else: status["hold_soc"]["soc"] = None