Skip to content

Commit

Permalink
Merge pull request #291 from fboundy/solis-cloud
Browse files Browse the repository at this point in the history
Solis cloud
  • Loading branch information
fboundy authored Nov 14, 2024
2 parents 6b02220 + a2a3a22 commit 96cc6fb
Show file tree
Hide file tree
Showing 6 changed files with 683 additions and 278 deletions.
227 changes: 227 additions & 0 deletions .test/solis_cloud_test.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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)
# %%
125 changes: 2 additions & 123 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# PV Opt: Home Assistant Solar/Battery Optimiser v3.17.1
# PV Opt: Home Assistant Solar/Battery Optimiser v3.18.0

<h2>This documentation needs updating!</h2>

Expand All @@ -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.

Expand Down Expand Up @@ -324,126 +323,6 @@ Restarts between Home Assistant and Add-Ons are not synchronised so it is helpfu
addon: a0d7b954_appdaemon
mode: single
<h3>14. For Solis-Control: Add Automation to Control Inverter</h3>
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: <<Your secret without quotes>>
key_id: "<<Your key id with quotes>>"
username: <<Your username without quotes>>
password: <<Your password without quotes>>
plantId: "<<Your plant_id with quotes>>"
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: <<Your secret without quotes>>
key_id: "<<Your key id with quotes>>"
username: <<Your username without quotes>>
password: <<Your password without quotes>>
plantId: "<<Your plant_id with quotes>>"
action: pyscript.solis_control
mode: single
```
<h2>Configuration</h2>
Expand Down
Loading

0 comments on commit 96cc6fb

Please sign in to comment.