update to 1.0.2
This commit is contained in:
BIN
source/src/.DS_Store
vendored
BIN
source/src/.DS_Store
vendored
Binary file not shown.
82
source/src/solaxx3.egg-info/PKG-INFO
Normal file
82
source/src/solaxx3.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,82 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: solaxx3
|
||||
Version: 1.0.2
|
||||
Summary: Read Solax X3 inverter registers via modbus interface (RS-485)
|
||||
Author-email: Flavius Moldovan <mkfam@protonmail.com>
|
||||
License: The MIT License (MIT)
|
||||
Copyright © 2022 <copyright Flavius Moldovan>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Project-URL: Homepage, https://github.com/mkfam7/solaxx3
|
||||
Keywords: Solax,solax-x3,solaxx3,solar inverter,RTU,MODBUS
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Requires-Python: >=3.8
|
||||
Description-Content-Type: text/markdown
|
||||
License-File: LICENSE
|
||||
Requires-Dist: pymodbus[serial]>=3.0.0
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
# solax-x3
|
||||
#### Read in real-time all parameters provided by Solax X3 solar inverter via its Modbus S-485 serial interface.
|
||||
|
||||
<br />
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* Solax X3 inverter
|
||||
* Modbus RS-485 serial adapter/interface
|
||||
* [Modbus cable](https://github.com/mkfam7/solaxx3/blob/main/diagrams/rs485_cable.png)
|
||||
* python version >= 3.8
|
||||
* This python module
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
pip install solaxx3
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
```
|
||||
from solaxx3.solaxx3 import SolaxX3
|
||||
|
||||
# adjust the serial port and baud rate as necessary
|
||||
s = SolaxX3(port="/dev/ttyUSB0", baudrate=115200)
|
||||
|
||||
if s.connect():
|
||||
s.read_all_registers()
|
||||
|
||||
available_stats = s.list_register_names()
|
||||
for stat in available_stats:
|
||||
print(stat)
|
||||
|
||||
battery_temperature = s.read("temperature_battery")
|
||||
print(f"\n\nBattery temperature: {s.read('temperature_battery')}")
|
||||
|
||||
|
||||
else:
|
||||
print("Cannot connect to the Modbus Server/Slave")
|
||||
exit()
|
||||
|
||||
|
||||
```
|
||||
|
||||
Project Link: [https://github.com/mkfam7/solaxx3](https://github.com/mkfam7/solaxx3)
|
||||
|
||||
|
||||
|
||||
16
source/src/solaxx3.egg-info/SOURCES.txt
Normal file
16
source/src/solaxx3.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
LICENSE
|
||||
README.md
|
||||
pyproject.toml
|
||||
setup.py
|
||||
src/solaxx3/__init__.py
|
||||
src/solaxx3/solax_registers_info.py
|
||||
src/solaxx3/solaxx3.py
|
||||
src/solaxx3/utils.py
|
||||
src/solaxx3.egg-info/PKG-INFO
|
||||
src/solaxx3.egg-info/SOURCES.txt
|
||||
src/solaxx3.egg-info/dependency_links.txt
|
||||
src/solaxx3.egg-info/requires.txt
|
||||
src/solaxx3.egg-info/top_level.txt
|
||||
tests/test_mysql_connector.py
|
||||
tests/test_register_data.py
|
||||
tests/test_solaxx3.py
|
||||
1
source/src/solaxx3.egg-info/dependency_links.txt
Normal file
1
source/src/solaxx3.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
source/src/solaxx3.egg-info/requires.txt
Normal file
1
source/src/solaxx3.egg-info/requires.txt
Normal file
@@ -0,0 +1 @@
|
||||
pymodbus[serial]>=3.0.0
|
||||
1
source/src/solaxx3.egg-info/top_level.txt
Normal file
1
source/src/solaxx3.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
solaxx3
|
||||
@@ -1,2 +1,3 @@
|
||||
# Version of the Solax RTU package
|
||||
__version__ = "0.0.6"
|
||||
"""Version of the Solax RTU package"""
|
||||
|
||||
__version__ = "1.0.2"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from pymodbus.client import ModbusSerialClient
|
||||
from datetime import date, datetime, timedelta
|
||||
from struct import *
|
||||
from solaxx3.registers import SolaxRegistersInfo
|
||||
from time import sleep, perf_counter
|
||||
|
||||
|
||||
class SolaxX3:
|
||||
connected: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
method="rtu",
|
||||
port="/dev/ttyUSB0",
|
||||
baudrate=115200,
|
||||
timeout=3,
|
||||
parity="N",
|
||||
stopbits=1,
|
||||
bytesize=8,
|
||||
) -> None:
|
||||
self._input_registers_values_list = []
|
||||
self._holding_registers_values_list = []
|
||||
|
||||
self.client = ModbusSerialClient(
|
||||
method=method,
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
timeout=timeout,
|
||||
parity=parity,
|
||||
stopbits=stopbits,
|
||||
bytesize=bytesize,
|
||||
)
|
||||
|
||||
def connect(self) -> bool:
|
||||
self.connected = self.client.connect()
|
||||
return self.connected
|
||||
|
||||
def _join_msb_lsb(self, msb: int, lsb: int) -> int:
|
||||
return (msb << 16) | lsb
|
||||
|
||||
def _unsigned16(self, type: str, addr: int, count: int = 1, unit: int = 1) -> int:
|
||||
|
||||
self._input_registers_values_list
|
||||
if type == "input":
|
||||
return self._input_registers_values_list[addr]
|
||||
elif type == "holding":
|
||||
return self._holding_registers_values_list[addr]
|
||||
|
||||
def _readRegisterRange(
|
||||
self, type: str, addr: int, count: int = 1, unit: int = 1
|
||||
) -> list:
|
||||
if type == "input":
|
||||
return self._input_registers_values_list[addr : addr + count]
|
||||
elif type == "holding":
|
||||
return self._holding_registers_values_list[addr : addr + count]
|
||||
|
||||
def _twos_complement(self, number: int, bits: int) -> int:
|
||||
"""
|
||||
Compute the 2's complement of the int value val
|
||||
"""
|
||||
|
||||
# if sign bit is set e.g., 8bit: 128-255
|
||||
if (number & (1 << (bits - 1))) != 0:
|
||||
|
||||
# compute negative value
|
||||
number = number - (1 << bits)
|
||||
|
||||
return number
|
||||
|
||||
def _read_register(self, register_type: str, register_info: dict) -> Any:
|
||||
"""Read the values from a register based on length and sign
|
||||
|
||||
Parameters:
|
||||
|
||||
register_info:dict - dictionary with register definition fields
|
||||
"""
|
||||
|
||||
if "int" in register_info["data_format"]:
|
||||
if register_info["data_length"] == 1:
|
||||
val = self._unsigned16(register_type, register_info["address"])
|
||||
|
||||
if register_info["data_length"] == 2:
|
||||
val = self._join_msb_lsb(
|
||||
self._unsigned16(register_type, register_info["address"] + 1),
|
||||
self._unsigned16(register_type, register_info["address"]),
|
||||
)
|
||||
|
||||
if register_info["signed"]:
|
||||
val = self._twos_complement(val, register_info["data_length"] * 16)
|
||||
|
||||
val = val / register_info["si_adj"]
|
||||
|
||||
elif "varchar" in register_info["data_format"]:
|
||||
block = self._readRegisterRange(
|
||||
register_type, register_info["address"], register_info["data_length"]
|
||||
)
|
||||
sn = []
|
||||
for i in range(register_info["data_length"]):
|
||||
first_byte, second_byte = unpack(
|
||||
"BB", int.to_bytes(block[i], 2, "little")
|
||||
)
|
||||
if not second_byte == 0x0:
|
||||
sn.append(chr(second_byte))
|
||||
if not first_byte == 0x0:
|
||||
sn.append(chr(first_byte))
|
||||
val = "".join(sn)
|
||||
|
||||
elif "datetime" in register_info["data_format"]:
|
||||
sec, min, hr, day, mon, year = self._readRegisterRange(
|
||||
register_type, register_info["address"], register_info["data_length"]
|
||||
)
|
||||
|
||||
inverter_datetime = (
|
||||
f"{(year+2000):02}-{mon:02}-{day:02} {hr:02}:{min:02}:{sec:02}"
|
||||
)
|
||||
val = datetime.strptime(inverter_datetime, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
return val
|
||||
|
||||
def read_register(self, register_info: dict) -> tuple:
|
||||
"""Read the values from a register based on length and sign
|
||||
|
||||
Parameters:
|
||||
|
||||
register_info:dict - dictionary with register definition fields
|
||||
"""
|
||||
|
||||
val = self._read_register(register_info["register_type"], register_info)
|
||||
|
||||
if not "data_unit" in register_info:
|
||||
return (val, "N/A")
|
||||
|
||||
return (val, register_info["data_unit"])
|
||||
|
||||
def read(self, name: str):
|
||||
"""Retrieve the value for the register with the provided name"""
|
||||
|
||||
r = SolaxRegistersInfo()
|
||||
|
||||
register_info = r.get_register_info(name)
|
||||
value = self.read_register(register_info)
|
||||
|
||||
return value
|
||||
|
||||
def list_register_names(self):
|
||||
r = SolaxRegistersInfo()
|
||||
return r.list_register_names()
|
||||
|
||||
def read_all_registers(self) -> None:
|
||||
self._input_registers_values_list = []
|
||||
self._holding_registers_values_list = []
|
||||
|
||||
read_block_length = 100
|
||||
for i in range(3):
|
||||
address = i * read_block_length
|
||||
values_list = self.client.read_input_registers(
|
||||
address=address, count=read_block_length, slave=1
|
||||
).registers
|
||||
self._input_registers_values_list.extend(values_list)
|
||||
|
||||
for i in range(3):
|
||||
address = i * read_block_length
|
||||
values_list = self.client.read_holding_registers(
|
||||
address=address, count=read_block_length, slave=1
|
||||
).registers
|
||||
self._holding_registers_values_list.extend(values_list)
|
||||
3570
source/src/solaxx3/solax_registers_info.py
Normal file
3570
source/src/solaxx3/solax_registers_info.py
Normal file
File diff suppressed because it is too large
Load Diff
198
source/src/solaxx3/solaxx3.py
Normal file
198
source/src/solaxx3/solaxx3.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Class loading and storing data from the inverter."""
|
||||
|
||||
from datetime import datetime
|
||||
from struct import unpack
|
||||
from typing import Dict, Iterable, List, Literal, Tuple, Union
|
||||
|
||||
from pymodbus.client import ModbusSerialClient
|
||||
|
||||
from .solax_registers_info import FIELD_VALUES, FIELDS, SolaxRegistersInfo
|
||||
from .utils import join_msb_lsb, twos_complement
|
||||
|
||||
REGISTER_VALUE = Union[float, str, datetime]
|
||||
REGISTER_INFO = Dict[FIELDS, FIELD_VALUES]
|
||||
|
||||
|
||||
class SolaxX3:
|
||||
"""
|
||||
Class interacting with values from the inverter.
|
||||
Initialization parameters:
|
||||
- method (default: rtu)
|
||||
- port (default: /dev/ttyUSB0 )
|
||||
- baudrate: Bits per second (default: 115,200 )
|
||||
- timeout: Timeout for a request, in seconds. (default: 3)
|
||||
- parity: 'E'ven, 'O'dd, 'N'one (default: N)
|
||||
- stopbits: Number of stop bits 0-2 (default: 1)
|
||||
- bytesize: Number of bits per byte (7 or 8) (default: 8)
|
||||
"""
|
||||
|
||||
connected: bool = False
|
||||
READ_BLOCK_LENGTH = 100
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
method: str = "rtu",
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baudrate: int = 115200,
|
||||
timeout: int = 3,
|
||||
parity: Literal["E", "O", "N"] = "N",
|
||||
stopbits: Literal[0, 1, 2] = 1,
|
||||
bytesize: Literal[7, 8] = 8,
|
||||
) -> None:
|
||||
self._input_registers_values: List[int] = []
|
||||
self._holding_registers_values: List[int] = []
|
||||
|
||||
self.client: ModbusSerialClient = ModbusSerialClient(
|
||||
method=method,
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
timeout=timeout,
|
||||
parity=parity,
|
||||
stopbits=stopbits,
|
||||
bytesize=bytesize,
|
||||
)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the inverter and return if it was successful."""
|
||||
|
||||
self.connected: bool = self.client.connect()
|
||||
return self.connected
|
||||
|
||||
def _get_unsigned_16(self, value_type: str, address: int) -> int:
|
||||
if value_type == "input":
|
||||
return self._input_registers_values[address]
|
||||
return self._holding_registers_values[address]
|
||||
|
||||
def _read_register_range(
|
||||
self, value_type: str, address: int, count: int = 1
|
||||
) -> list:
|
||||
if value_type == "input":
|
||||
return self._input_registers_values[address : address + count]
|
||||
return self._holding_registers_values[address : address + count]
|
||||
|
||||
def _read_format_register_value(
|
||||
self, register_info: REGISTER_INFO
|
||||
) -> REGISTER_VALUE:
|
||||
"""Read the values from a register based on length and sign
|
||||
|
||||
Parameters:
|
||||
register_info:dict - dictionary with register definition fields
|
||||
"""
|
||||
|
||||
if self._is_register_type_integer(register_info):
|
||||
value = self._get_integer_value(register_info)
|
||||
value = value / register_info["si_adj"]
|
||||
|
||||
elif self._is_register_type_string(register_info):
|
||||
value = self._get_string_value(register_info)
|
||||
|
||||
else:
|
||||
value = self._get_datetime_value(register_info)
|
||||
|
||||
return value
|
||||
|
||||
def _get_datetime_value(self, register_info: REGISTER_INFO) -> datetime:
|
||||
register_type = register_info["register_type"]
|
||||
|
||||
sec, minute, hr, day, mon, year = self._read_register_range(
|
||||
register_type, register_info["address"], register_info["data_length"]
|
||||
)
|
||||
inverter_datetime = f"{year:02}-{mon:02}-{day:02} {hr:02}:{minute:02}:{sec:02}"
|
||||
value = datetime.strptime(inverter_datetime, "%y-%m-%d %H:%M:%S")
|
||||
return value
|
||||
|
||||
def _is_register_type_datetime(self, register_info: REGISTER_INFO) -> bool:
|
||||
return "datetime" in register_info["data_format"]
|
||||
|
||||
def _get_string_value(self, register_info: REGISTER_INFO) -> str:
|
||||
characters: List[str] = []
|
||||
register_type: Literal["input", "holding"] = register_info["register_type"]
|
||||
block = self._read_register_range(
|
||||
register_type, register_info["address"], register_info["data_length"]
|
||||
)
|
||||
|
||||
for i in range(register_info["data_length"]):
|
||||
first_byte, second_byte = unpack("BB", int.to_bytes(block[i], 2, "little"))
|
||||
if not second_byte == 0x0:
|
||||
characters.append(chr(second_byte))
|
||||
if not first_byte == 0x0:
|
||||
characters.append(chr(first_byte))
|
||||
|
||||
return "".join(characters)
|
||||
|
||||
def _is_register_type_string(self, register_info: REGISTER_INFO) -> bool:
|
||||
return "varchar" in register_info["data_format"]
|
||||
|
||||
def _get_integer_value(self, register_info: REGISTER_INFO) -> int:
|
||||
register_type = register_info["register_type"]
|
||||
val = 0
|
||||
|
||||
if register_info["data_length"] == 1:
|
||||
val = self._get_unsigned_16(register_type, register_info["address"])
|
||||
|
||||
if register_info["data_length"] == 2:
|
||||
val = join_msb_lsb(
|
||||
self._get_unsigned_16(register_type, register_info["address"] + 1),
|
||||
self._get_unsigned_16(register_type, register_info["address"]),
|
||||
)
|
||||
|
||||
if register_info["signed"]:
|
||||
val = twos_complement(val, register_info["data_length"] * 16)
|
||||
return val
|
||||
|
||||
def _is_register_type_integer(self, register_info: REGISTER_INFO) -> bool:
|
||||
return "int" in register_info["data_format"]
|
||||
|
||||
def _read_register_value(
|
||||
self, register_info: REGISTER_INFO
|
||||
) -> Tuple[REGISTER_VALUE, str]:
|
||||
"""Read the values from a register based on length and sign.
|
||||
|
||||
Parameters:
|
||||
:param register_info: dictionary with register definition fields
|
||||
:return: Tuple containing the value and data unit
|
||||
"""
|
||||
|
||||
val = self._read_format_register_value(register_info)
|
||||
return (val, register_info["data_unit"])
|
||||
|
||||
def read(self, name: str) -> Tuple[REGISTER_VALUE, str]:
|
||||
"""Retrieve the value for the register with the provided name"""
|
||||
|
||||
registers = SolaxRegistersInfo()
|
||||
register_info = registers.get_register_info(name)
|
||||
value_data_unit = self._read_register_value(register_info)
|
||||
return value_data_unit
|
||||
|
||||
def list_register_names(self) -> list:
|
||||
"""Return all registers defined in register info."""
|
||||
|
||||
r = SolaxRegistersInfo()
|
||||
return r.list_register_names()
|
||||
|
||||
def read_all_registers(self) -> None:
|
||||
"""Read all register values from inverter."""
|
||||
|
||||
self._input_registers_values: List[int] = []
|
||||
self._holding_registers_values: List[int] = []
|
||||
|
||||
self._read_input_registers()
|
||||
self._read_holding_registers()
|
||||
|
||||
def _read_holding_registers(self):
|
||||
for count in range(4):
|
||||
address: int = count * self.READ_BLOCK_LENGTH
|
||||
|
||||
values: Iterable = self.client.read_holding_registers(
|
||||
address=address, count=self.READ_BLOCK_LENGTH, slave=1
|
||||
).registers
|
||||
self._holding_registers_values.extend(values)
|
||||
|
||||
def _read_input_registers(self):
|
||||
for count in range(4):
|
||||
address: int = count * self.READ_BLOCK_LENGTH
|
||||
|
||||
values: Iterable = self.client.read_input_registers(
|
||||
address=address, count=self.READ_BLOCK_LENGTH, slave=1
|
||||
).registers
|
||||
self._input_registers_values.extend(values)
|
||||
18
source/src/solaxx3/utils.py
Normal file
18
source/src/solaxx3/utils.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""SolaxX3 utils module performing binary operations."""
|
||||
|
||||
|
||||
def join_msb_lsb(msb: int, lsb: int) -> int:
|
||||
"""Join two 16-bit registers into a 32-bit register."""
|
||||
|
||||
return (msb << 16) | lsb
|
||||
|
||||
|
||||
def twos_complement(number: int, bits: int) -> int:
|
||||
"""Compute the 2's complement of the provided integer."""
|
||||
|
||||
# if sign bit is set e.g., 8bit: 128-255
|
||||
if (number & (1 << (bits - 1))) != 0:
|
||||
# compute negative value
|
||||
number = number - (1 << bits)
|
||||
|
||||
return number
|
||||
Reference in New Issue
Block a user