Skip to content

Commit

Permalink
Add support for Modbus/TCP communication protocol
Browse files Browse the repository at this point in the history
Rename the current Modbus/RTU over UDP code to "rtu" and add equivalent implementations for the standard Modbus/TCP protocol.

UdpInverterProtocol now represents Modbus/RTU over UDP (goodwe way).
TcpInverterProtocol now represents Modbus/TCP (standard way).
  • Loading branch information
mletenay committed Apr 23, 2024
1 parent 28642e3 commit f963da1
Show file tree
Hide file tree
Showing 9 changed files with 368 additions and 98 deletions.
12 changes: 6 additions & 6 deletions goodwe/dt.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .inverter import OperationMode
from .inverter import SensorKind as Kind
from .model import is_3_mppt, is_single_phase
from .protocol import ProtocolCommand, ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
from .protocol import ProtocolCommand
from .sensor import *


Expand Down Expand Up @@ -127,8 +127,8 @@ def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, r
if not self.comm_addr:
# Set the default inverter address
self.comm_addr = 0x7f
self._READ_DEVICE_VERSION_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x7531, 0x0028)
self._READ_DEVICE_RUNNING_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x7594, 0x0049)
self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x7531, 0x0028)
self._READ_DEVICE_RUNNING_DATA: ProtocolCommand = self._read_command(0x7594, 0x0049)
self._sensors = self.__all_sensors
self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings}

Expand Down Expand Up @@ -180,7 +180,7 @@ async def read_setting(self, setting_id: str) -> Any:
if not setting:
raise ValueError(f'Unknown setting "{setting_id}"')
count = (setting.size_ + (setting.size_ % 2)) // 2
response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
response = await self._read_from_socket(self._read_command(setting.offset, count))
return setting.read_value(response)

async def write_setting(self, setting_id: str, value: Any):
Expand All @@ -190,9 +190,9 @@ async def write_setting(self, setting_id: str, value: Any):
raw_value = setting.encode_value(value)
if len(raw_value) <= 2:
value = int.from_bytes(raw_value, byteorder="big", signed=True)
await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
await self._read_from_socket(self._write_command(setting.offset, value))
else:
await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
await self._read_from_socket(self._write_multi_command(setting.offset, raw_value))

async def read_settings_data(self) -> Dict[str, Any]:
data = {}
Expand Down
11 changes: 5 additions & 6 deletions goodwe/es.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
from .inverter import Inverter
from .inverter import OperationMode
from .inverter import SensorKind as Kind
from .protocol import ProtocolCommand, Aa55ProtocolCommand, Aa55ReadCommand, Aa55WriteCommand, Aa55WriteMultiCommand, \
ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
from .protocol import ProtocolCommand, Aa55ProtocolCommand, Aa55ReadCommand, Aa55WriteCommand, Aa55WriteMultiCommand
from .sensor import *

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -228,7 +227,7 @@ async def read_setting(self, setting_id: str) -> Any:
async def _read_setting(self, setting: Sensor) -> Any:
count = (setting.size_ + (setting.size_ % 2)) // 2
if self._is_modbus_setting(setting):
response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
response = await self._read_from_socket(self._read_command(setting.offset, count))
return setting.read_value(response)
else:
response = await self._read_from_socket(Aa55ReadCommand(setting.offset, count))
Expand All @@ -249,7 +248,7 @@ async def _write_setting(self, setting: Sensor, value: Any):
if setting.size_ == 1:
# modbus can address/store only 16 bit values, read the other 8 bytes
if self._is_modbus_setting(setting):
response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1))
response = await self._read_from_socket(self._read_command(setting.offset, 1))
raw_value = setting.encode_value(value, response.response_data()[0:2])
else:
response = await self._read_from_socket(Aa55ReadCommand(setting.offset, 1))
Expand All @@ -259,12 +258,12 @@ async def _write_setting(self, setting: Sensor, value: Any):
if len(raw_value) <= 2:
value = int.from_bytes(raw_value, byteorder="big", signed=True)
if self._is_modbus_setting(setting):
await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
await self._read_from_socket(self._write_command(setting.offset, value))
else:
await self._read_from_socket(Aa55WriteCommand(setting.offset, value))
else:
if self._is_modbus_setting(setting):
await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
await self._read_from_socket(self._write_multi_command(setting.offset, raw_value))
else:
await self._read_from_socket(Aa55WriteMultiCommand(setting.offset, raw_value))

Expand Down
36 changes: 20 additions & 16 deletions goodwe/et.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .inverter import OperationMode
from .inverter import SensorKind as Kind
from .model import is_2_battery, is_4_mppt, is_745_platform, is_single_phase
from .protocol import ProtocolCommand, ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
from .protocol import ProtocolCommand
from .sensor import *

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -152,6 +152,10 @@ class ET(Inverter):
read_bytes4_signed(data, 35182) -
read_bytes2_signed(data, 35140),
"House Consumption", "W", Kind.AC),

# Power4S("pbattery2", 35264, "Battery2 Power", Kind.BAT),
# Integer("battery2_mode", 35266, "Battery2 Mode code", "", Kind.BAT),
# Enum2("battery2_mode_label", 35184, BATTERY_MODES, "Battery2 Mode", Kind.BAT),
)

# Modbus registers from offset 0x9088 (37000)
Expand Down Expand Up @@ -410,13 +414,13 @@ def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, r
if not self.comm_addr:
# Set the default inverter address
self.comm_addr = 0xf7
self._READ_DEVICE_VERSION_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x88b8, 0x0021)
self._READ_RUNNING_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x891c, 0x007d)
self._READ_METER_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x8ca0, 0x2d)
self._READ_METER_DATA_EXTENDED: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x8ca0, 0x3a)
self._READ_BATTERY_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x9088, 0x0018)
self._READ_BATTERY2_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x9858, 0x0016)
self._READ_MPPT_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x89e5, 0x3d)
self._READ_DEVICE_VERSION_INFO: ProtocolCommand = self._read_command(0x88b8, 0x0021)
self._READ_RUNNING_DATA: ProtocolCommand = self._read_command(0x891c, 0x007d)
self._READ_METER_DATA: ProtocolCommand = self._read_command(0x8ca0, 0x2d)
self._READ_METER_DATA_EXTENDED: ProtocolCommand = self._read_command(0x8ca0, 0x3a)
self._READ_BATTERY_INFO: ProtocolCommand = self._read_command(0x9088, 0x0018)
self._READ_BATTERY2_INFO: ProtocolCommand = self._read_command(0x9858, 0x0016)
self._READ_MPPT_DATA: ProtocolCommand = self._read_command(0x89e5, 0x3d)
self._has_eco_mode_v2: bool = True
self._has_peak_shaving: bool = True
self._has_battery: bool = True
Expand Down Expand Up @@ -478,7 +482,7 @@ async def read_device_info(self):

# Check and add EcoModeV2 settings added in (ETU fw 19)
try:
await self._read_from_socket(ModbusReadCommand(self.comm_addr, 47547, 6))
await self._read_from_socket(self._read_command(47547, 6))
self._settings.update({s.id_: s for s in self.__settings_arm_fw_19})
except RequestRejectedException as ex:
if ex.message == 'ILLEGAL DATA ADDRESS':
Expand All @@ -487,7 +491,7 @@ async def read_device_info(self):

# Check and add Peak Shaving settings added in (ETU fw 22)
try:
await self._read_from_socket(ModbusReadCommand(self.comm_addr, 47589, 6))
await self._read_from_socket(self._read_command(47589, 6))
self._settings.update({s.id_: s for s in self.__settings_arm_fw_22})
except RequestRejectedException as ex:
if ex.message == 'ILLEGAL DATA ADDRESS':
Expand Down Expand Up @@ -560,7 +564,7 @@ async def read_setting(self, setting_id: str) -> Any:

async def _read_setting(self, setting: Sensor) -> Any:
count = (setting.size_ + (setting.size_ % 2)) // 2
response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
response = await self._read_from_socket(self._read_command(setting.offset, count))
return setting.read_value(response)

async def write_setting(self, setting_id: str, value: Any):
Expand All @@ -572,15 +576,15 @@ async def write_setting(self, setting_id: str, value: Any):
async def _write_setting(self, setting: Sensor, value: Any):
if setting.size_ == 1:
# modbus can address/store only 16 bit values, read the other 8 bytes
response = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1))
response = await self._read_from_socket(self._read_command(setting.offset, 1))
raw_value = setting.encode_value(value, response.response_data()[0:2])
else:
raw_value = setting.encode_value(value)
if len(raw_value) <= 2:
value = int.from_bytes(raw_value, byteorder="big", signed=True)
await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
await self._read_from_socket(self._write_command(setting.offset, value))
else:
await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
await self._read_from_socket(self._write_multi_command(setting.offset, raw_value))

async def read_settings_data(self) -> Dict[str, Any]:
data = {}
Expand Down Expand Up @@ -694,8 +698,8 @@ def settings(self) -> Tuple[Sensor, ...]:
return tuple(self._settings.values())

async def _clear_battery_mode_param(self) -> None:
await self._read_from_socket(ModbusWriteCommand(self.comm_addr, 0xb9ad, 1))
await self._read_from_socket(self._write_command(0xb9ad, 1))

async def _set_offline(self, mode: bool) -> None:
value = bytes.fromhex('00070000') if mode else bytes.fromhex('00010000')
await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, 0xb997, value))
await self._read_from_socket(self._write_multi_command(0xb997, value))
12 changes: 12 additions & 0 deletions goodwe/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ def __init__(self, host: str, port: int, comm_addr: int = 0, timeout: int = 1, r
self.arm_version: int = 0
self.arm_svn_version: int | None = None

def _read_command(self, offset: int, count: int) -> ProtocolCommand:
"""Create read protocol command."""
return self._protocol.read_command(self.comm_addr, offset, count)

def _write_command(self, register: int, value: int) -> ProtocolCommand:
"""Create write protocol command."""
return self._protocol.write_command(self.comm_addr, register, value)

def _write_multi_command(self, offset: int, values: bytes) -> ProtocolCommand:
"""Create write multiple protocol command."""
return self._protocol.write_multi_command(self.comm_addr, offset, values)

def _ensure_lock(self) -> asyncio.Lock:
"""Validate (or create) asyncio Lock.
Expand Down
112 changes: 106 additions & 6 deletions goodwe/modbus.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ def _modbus_checksum(data: Union[bytearray, bytes]) -> int:
return crc


def create_modbus_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes:
def create_modbus_rtu_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes:
"""
Create modbus request.
Create modbus RTU request.
data[0] is inverter address
data[1] is modbus command
data[2:3] is command offset parameter
Expand All @@ -74,9 +74,36 @@ def create_modbus_request(comm_addr: int, cmd: int, offset: int, value: int) ->
return bytes(data)


def create_modbus_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes:
def create_modbus_tcp_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes:
"""
Create modbus (multi value) request.
Create modbus TCP request.
data[0:1] is transaction identifier
data[2:3] is protocol identifier (0)
data[4:5] message length
data[6] is inverter address
data[7] is modbus command
data[8:9] is command offset parameter
data[10:11] is command value parameter
"""
data: bytearray = bytearray(12)
data[0] = 0
data[1] = 1 # Not transaction ID support yet
data[2] = 0
data[3] = 0
data[4] = 0
data[5] = 6
data[6] = comm_addr
data[7] = cmd
data[8] = (offset >> 8) & 0xFF
data[9] = offset & 0xFF
data[10] = (value >> 8) & 0xFF
data[11] = value & 0xFF
return bytes(data)


def create_modbus_rtu_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes:
"""
Create modbus RTU (multi value) request.
data[0] is inverter address
data[1] is modbus command
data[2:3] is command offset parameter
Expand All @@ -100,9 +127,40 @@ def create_modbus_multi_request(comm_addr: int, cmd: int, offset: int, values: b
return bytes(data)


def validate_modbus_response(data: bytes, cmd: int, offset: int, value: int) -> bool:
def create_modbus_tcp_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes:
"""
Create modbus TCP (multi value) request.
data[0:1] is transaction identifier
data[2:3] is protocol identifier (0)
data[4:5] message length
data[6] is inverter address
data[7] is modbus command
data[8:9] is command offset parameter
data[10:11] is number of registers
data[12] is number of bytes
data[13-n] is data payload
"""
Validate the modbus response.
data: bytearray = bytearray(13)
data[0] = 0
data[1] = 1 # Not transaction ID support yet
data[2] = 0
data[3] = 0
data[4] = 0
data[5] = 7 + len(values)
data[6] = comm_addr
data[7] = cmd
data[8] = (offset >> 8) & 0xFF
data[9] = offset & 0xFF
data[10] = 0
data[11] = len(values) // 2
data[12] = len(values)
data.extend(values)
return bytes(data)


def validate_modbus_rtu_response(data: bytes, cmd: int, offset: int, value: int) -> bool:
"""
Validate the modbus RTU response.
data[0:1] is header
data[2] is source address
data[3] is command return type
Expand Down Expand Up @@ -147,3 +205,45 @@ def validate_modbus_response(data: bytes, cmd: int, offset: int, value: int) ->
raise RequestRejectedException(failure_code)

return True


def validate_modbus_tcp_response(data: bytes, cmd: int, offset: int, value: int) -> bool:
"""
Validate the modbus TCP response.
data[0:1] is transaction identifier
data[2:3] is protocol identifier (0)
data[4:5] message length
data[6] is source address
data[7] is command return type
data[8] is response payload length (for read commands)
"""
if len(data) <= 8:
logger.debug("Response is too short.")
return False
if data[7] == MODBUS_READ_CMD:
if data[8] != value * 2:
logger.debug("Response has unexpected length: %d, expected %d.", data[8], value * 2)
return False
expected_length = data[8] + 9
if len(data) < expected_length:
logger.debug("Response is too short: %d, expected %d.", len(data), expected_length)
return False
elif data[7] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
if len(data) < 12:
logger.debug("Response has unexpected length: %d, expected %d.", len(data), 14)
return False
response_offset = int.from_bytes(data[8:10], byteorder='big', signed=False)
if response_offset != offset:
logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset)
return False
response_value = int.from_bytes(data[10:12], byteorder='big', signed=True)
if response_value != value:
logger.debug("Response has wrong value: %X, expected %X.", response_value, value)
return False

if data[7] != cmd:
failure_code = FAILURE_CODES.get(data[8], "UNKNOWN")
logger.debug("Response is command failure: %s.", FAILURE_CODES.get(data[8], "UNKNOWN"))
raise RequestRejectedException(failure_code)

return True
Loading

0 comments on commit f963da1

Please sign in to comment.