Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add new inverter and AES encryption #137

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"aiohttp>=3.5.4, <4",
"async_timeout>=4.0.2",
"voluptuous>=0.11.5",
"pycryptodome>=3.19.0",
],
setup_requires=[
"setuptools_scm",
Expand Down
110 changes: 110 additions & 0 deletions solax/data_encryption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import json
import aiohttp
import base64

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad


class DataEncrypt:
"""
The real-time data needs to be AES encrypted
"""

def __init__(self, serial_number: str, url: str):
self.url = url
self.iv = bytes(
[0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f])
self.serial_number = str(serial_number)
self.login_request_body = "optType=newParaSetting&subOption=pwd&Value=" + self.serial_number
self.real_time_body = "optType=ReadRealTimeData&pwd=" + self.serial_number
self.key = ""
self.token = ""

async def encrypt(self, plain_text: bytes):
aes = AES.new(self.key, AES.MODE_CBC, self.iv)
cipher_text = aes.encrypt(pad(plain_text, AES.block_size))
return cipher_text

async def decrypt(self, cipher_text: bytes):
aes = AES.new(self.key, AES.MODE_CBC, self.iv)
decrypted_text = unpad(aes.decrypt(cipher_text), AES.block_size)
return decrypted_text

async def get_token_key(self, datahub_sn: str):
"""
Generate AES encrypted key based on SN number
"""
ret = ''
mqtt_login_password = [0] * 8
mqtt_login_password[0] = datahub_sn[7]
mqtt_login_password[1] = datahub_sn[4]
mqtt_login_password[2] = datahub_sn[3]
mqtt_login_password[3] = datahub_sn[6]
mqtt_login_password[4] = datahub_sn[5]
mqtt_login_password[5] = datahub_sn[2]
mqtt_login_password[6] = datahub_sn[9]
mqtt_login_password[7] = datahub_sn[8]
for i in range(0, len(mqtt_login_password)):
mqtt_login_password[i] = chr(ord(mqtt_login_password[i]) ^ 11)
if mqtt_login_password[i].isalpha() or mqtt_login_password[i].isdigit():
ret += mqtt_login_password[i]
else:
ret += 'A'

return ret.encode('utf-8').hex()

async def fill_16_byte(self, original_hex: str):
"""
Complete the string length to 16 bits
"""
byte_string = bytes.fromhex(original_hex)
padding_bytes = 16 - len(byte_string)
padded_byte_string = byte_string + bytes([0] * padding_bytes)
padded_hex_string = padded_byte_string.hex()
return bytes.fromhex(padded_hex_string)

async def get_token(self, encodebytes: bytes):
decrypted_text = await self.decrypt(encodebytes)
res_data = decrypted_text.decode()
res_data_json = json.loads(res_data)
self.token = res_data_json.get("data").get("token")

async def get_real_time(self) -> bytes:
headers = {
"token": self.token,
"Content-Type": "application/json"
}
real_time_text = await self.encrypt(self.real_time_body.encode('utf8'))

real_time_base64 = base64.b64encode(real_time_text)

real_time_data = real_time_base64.decode('utf8')
async with aiohttp.ClientSession() as session:
async with session.post(self.url, headers=headers, data=real_time_data) as req:
response = await req.text()

real_time = response
encode_bytes = base64.decodebytes(real_time.encode('utf-8'))
decrypted_text = await self.decrypt(encode_bytes)

return decrypted_text

async def get_encrypt_data(self) -> bytes:
"""
Decrypt data By AES
"""
self.key = await self.fill_16_byte(await self.get_token_key(self.serial_number))

encrypt_text = await self.encrypt(self.login_request_body.encode('utf8'))

encode_strs = base64.b64encode(encrypt_text)

login_data = encode_strs.decode('utf8')
async with aiohttp.ClientSession() as session:
async with session.post(self.url, data=login_data) as req:
response = await req.text()

encodebytes = base64.decodebytes(response.encode('utf-8'))
await self.get_token(encodebytes)
return await self.get_real_time()
35 changes: 16 additions & 19 deletions solax/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,34 @@

from solax.inverter import Inverter, InverterError
from solax.inverters import (
QVOLTHYBG33P,
X1,
X3,
X3V34,
X1Boost,
X1HybridGen4,
X1Mini,
X1MiniV34,
X1Smart,
X3HybridG4,
X3MicProG2,
XHybrid,
X1HybridG2,
X1MiniG3,
X1MiniG4,
X1HybridLv,
X1Ies,
X3Ies,
X3Ultra,
X1BoostG4
)

# registry of inverters
REGISTRY = [
XHybrid,
X3,
X3V34,
X3HybridG4,
X1,
X1Mini,
X1MiniV34,
X1Smart,
QVOLTHYBG33P,
X1Boost,
X1HybridGen4,
X3MicProG2,
X1HybridG2,
X1Ies,
X1MiniG3,
X1MiniG4,
X1HybridLv,
X3Ies,
X3Ultra,
X1BoostG4,
]


logging.basicConfig(level=logging.INFO)


Expand Down
27 changes: 20 additions & 7 deletions solax/inverter.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from typing import Dict, Tuple

import aiohttp
import voluptuous as vol

from solax import utils
from solax.inverter_http_client import InverterHttpClient, Method
from solax.response_parser import InverterResponse, ResponseDecoder, ResponseParser
from solax.units import Measurement, Units
from solax.data_encryption import DataEncrypt


class InverterError(Exception):
Expand All @@ -26,18 +26,21 @@ def response_decoder(cls) -> ResponseDecoder:

# pylint: enable=C0301
_schema = vol.Schema({}) # type: vol.Schema
url: str = ''
pwd: str = ''

def __init__(
self, http_client: InverterHttpClient, response_parser: ResponseParser
self, http_client: InverterHttpClient, response_parser: ResponseParser
):
self.manufacturer = "Solax"
self.response_parser = response_parser
self.http_client = http_client

@classmethod
def _build(cls, host, port, pwd="", params_in_query=True):
url = utils.to_url(host, port)
http_client = InverterHttpClient(url, Method.POST, pwd)
cls.url = utils.to_url(host, port)
cls.pwd = pwd
http_client = InverterHttpClient(cls.url, Method.POST, cls.pwd)
if params_in_query:
http_client.with_default_query()
else:
Expand Down Expand Up @@ -72,7 +75,19 @@ async def make_request(self) -> InverterResponse:
Return instance of 'InverterResponse'
Raise exception if unable to get data
"""
raw_response = await self.http_client.request()
for i in range(3):
try:
raw_response = await self.http_client.request()
break
except aiohttp.ClientError as ex:
print('request error:', ex)
str_raw_response = raw_response.decode('utf-8')
if str_raw_response.startswith('{"code":'):
pass
else:
if not str_raw_response.startswith('{"sn"'):
raw_response = await DataEncrypt(self.pwd, self.url).get_encrypt_data()

return self.response_parser.handle_response(raw_response)

@classmethod
Expand All @@ -84,9 +99,7 @@ def sensor_map(cls) -> Dict[str, Tuple[int, Measurement]]:
sensors: Dict[str, Tuple[int, Measurement]] = {}
for name, mapping in cls.response_decoder().items():
unit = Measurement(Units.NONE)

(idx, unit_or_measurement, *_) = mapping

if isinstance(unit_or_measurement, Units):
unit = Measurement(unit_or_measurement)
else:
Expand Down
18 changes: 18 additions & 0 deletions solax/inverters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
from .x3_mic_pro_g2 import X3MicProG2
from .x3_v34 import X3V34
from .x_hybrid import XHybrid
from .j1_ess_hb import J1EssHb
from .x1_hybrid_g2 import X1HybridG2
from .x1_mini_g4 import X1MiniG4
from .x1_mini_g3 import X1MiniG3
from .x1_hybrid_lv import X1HybridLv
from .x1_ies import X1Ies
from .x3_ies import X3Ies
from .x3_ultra import X3Ultra
from .x1_boost_g4 import X1BoostG4

__all__ = [
"QVOLTHYBG33P",
Expand All @@ -24,4 +33,13 @@
"X1Boost",
"X1HybridGen4",
"X3MicProG2",
"J1EssHb",
"X1HybridG2",
"X1MiniG3",
"X1MiniG4",
"X1HybridLv",
"X1Ies",
"X3Ies",
"X3Ultra",
"X1BoostG4"
]
86 changes: 86 additions & 0 deletions solax/inverters/j1_ess_hb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import voluptuous as vol

from solax.inverter import Inverter
from solax.units import Total, Units
from solax.utils import div10, div100, pack_u16, to_signed, to_signed32, twoway_div10


class J1EssHb(Inverter):
# pylint: disable=duplicate-code
_schema = vol.Schema(
{
vol.Required("type"): int,
vol.Required("sn"): str,
vol.Required("ver"): str,
vol.Required("Data"): vol.Schema(
vol.All(
[vol.Coerce(float)],
vol.Length(min=300, max=300),
)
),
vol.Required("Information"): vol.Schema(
vol.All(vol.Length(min=10, max=10))
),
},
extra=vol.REMOVE_EXTRA,
)

@classmethod
def build_all_variants(cls, host, port, pwd=""):
return [cls._build(host, port, pwd, False)]

@classmethod
def _decode_run_mode(cls, run_mode):
return {
0: "Waiting",
1: "Checking",
2: "Normal",
3: "Fault",
4: "Permanent Fault",
5: "Updating",
6: "EPS Check",
7: "EPS Mode",
8: "Self Test",
9: "Idle",
10: "Standby",
}.get(run_mode)

@classmethod
def response_decoder(cls):
return {
"Grid 1 Voltage": (0, Units.V, div10),
"Grid 1 Current": (1, Units.A, div10),
"Grid 1 Power": (2, Units.W, to_signed),
"Grid 2 Voltage": (3, Units.V, div10),
"Grid 2 Current": (4, Units.A, div10),
"Grid 2 Power": (5, Units.W, to_signed),
"Grid Power Total": (6, Units.W, to_signed),
"Grid Frequency": (7, Units.HZ, div100),
"PV1 Voltage": (8, Units.V, div10),
"PV1 Current": (9, Units.A, div10),
"PV1 Power": (10, Units.W),
"PV2 Voltage": (11, Units.V, div10),
"PV2 Current": (12, Units.A, div10),
"PV2 Power": (13, Units.W),
"PV3 Voltage": (14, Units.V, div10),
"PV3 Current": (15, Units.A, div10),
"PV3 Power": (16, Units.W),
"Run Mode": (17, Units.NONE, J1EssHb._decode_run_mode),
"EPS 1 Voltage": (19, Units.V, div10),
"EPS 1 Current": (20, Units.A, twoway_div10),
"EPS 1 Power": (21, Units.W, to_signed),
"EPS 2 Voltage": (23, Units.V, div10),
"EPS 2 Current": (24, Units.A, twoway_div10),
"EPS 2 Power": (25, Units.W, to_signed),
"EPS Frequency": (26, Units.HZ, div100),
"Feed-in 1 Power ": (27, Units.W, to_signed),
"Feed-in 2 Power ": (28, Units.W, to_signed),
"Feed-in Power Total ": (29, Units.W, to_signed),
"Yield total": (pack_u16(34, 35), Total(Units.KWH), div10),
"Yield today": (36, Units.KWH, div10),
"Battery Remaining Capacity": (80, Units.PERCENT),
"Battery Temperature": (82, Units.C),
"Battery Surplus Energy": (83, Units.KWH, div10),
}

# pylint: enable=duplicate-code
Loading
Loading