Files
SolarManager/goodwe/protocol.py
T
adminandClaude Opus 5 79843aa2ae SolarManager unter Versionsverwaltung
Erster Stand der Hintergrundprozesse, die auf der Synology unter
/volume1/homes/wagner/SolarManager laufen: der Manager selbst, die Sammler
je Geraet, die MQTT-Bruecke, der Wecker und - neu hinzugezogen - der
AutoAction-Runner, der als Hintergrundprozess hierher gehoert und nicht ins
Web-Verzeichnis.

Zugangsdaten stehen nicht mehr im Quelltext, sondern in config.ini, die
nicht mit eingecheckt wird. Vorlage ist config.ini.example, gelesen wird sie
von konfig.py. Betroffen waren solarManager.py (Datenbank und Wattpilot),
zeit.py, gatherWaterData.py, wecker.py und skoda_testdaten.py, das sich das
Passwort bisher aus dem Quelltext eines anderen Moduls herausgesucht hat.

Die Kia-Anbindung ist mit dem Fahrzeug entfallen: kiaTest.py,
gatherCarData.py und hyundai_kia_connect_api sind nicht mehr dabei, ebenso
gatherInverterData.py, auf das nur noch eine auskommentierte Zeile zeigte.

Die mitgelieferten Bibliotheken bleiben im Repository - die NAS hat kein
pip, sie muessen neben den Skripten liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:46:59 +02:00

271 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import logging
from asyncio.futures import Future
from typing import Tuple, Optional, Callable
from .const import GOODWE_UDP_PORT
from .exceptions import MaxRetriesException, RequestFailedException, RequestRejectedException
from .modbus import create_modbus_request, create_modbus_multi_request, validate_modbus_response, MODBUS_READ_CMD, \
MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD
logger = logging.getLogger(__name__)
class UdpInverterProtocol(asyncio.DatagramProtocol):
def __init__(
self,
response_future: Future,
command: ProtocolCommand,
timeout: int,
retries: int
):
super().__init__()
self.response_future: Future = response_future
self.command: ProtocolCommand = command
self._transport: asyncio.transports.DatagramTransport | None = None
self._retry_timeout: int = timeout
self._max_retries: int = retries
self._retries: int = 0
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
"""On connection made"""
self._transport = transport
self._send_request()
def connection_lost(self, exc: Optional[Exception]) -> None:
"""On connection lost"""
if exc is not None:
logger.debug("Socket closed with error: %s.", exc)
# Cancel Future on connection lost
if not self.response_future.done():
self.response_future.cancel()
def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
"""On datagram received"""
try:
if self.command.validator(data):
logger.debug("Received: %s", data.hex())
self.response_future.set_result(data)
else:
logger.debug("Received invalid response: %s", data.hex())
self._retries += 1
self._send_request()
except RequestRejectedException as ex:
logger.debug("Received exception response: %s", data.hex())
self.response_future.set_exception(ex)
def error_received(self, exc: Exception) -> None:
"""On error received"""
logger.debug("Received error: %s", exc)
self.response_future.set_exception(exc)
def _send_request(self) -> None:
"""Send message via transport"""
logger.debug("Sending: %s%s", self.command,
f' - retry #{self._retries}/{self._max_retries}' if self._retries > 0 else '')
self._transport.sendto(self.command.request)
asyncio.get_event_loop().call_later(self._retry_timeout, self._retry_mechanism)
def _retry_mechanism(self) -> None:
"""Retry mechanism to prevent hanging transport"""
if self.response_future.done():
self._transport.close()
elif self._retries < self._max_retries:
logger.debug("Failed to receive response to %s in time (%ds).", self.command, self._retry_timeout)
self._retries += 1
self._send_request()
else:
logger.debug("Max number of retries (%d) reached, request %s failed.", self._max_retries, self.command)
self.response_future.set_exception(MaxRetriesException)
class ProtocolCommand:
"""Definition of inverter protocol command"""
def __init__(self, request: bytes, validator: Callable[[bytes], bool]):
self.request: bytes = request
self.validator: Callable[[bytes], bool] = validator
def __repr__(self):
return self.request.hex()
async def execute(self, host: str, timeout: int, retries: int) -> bytes:
"""
Execute the udp protocol command on the specified address/port.
Since the UDP communication is by definition unreliable, when no (valid) response is received by specified
timeout, the command will be re-tried up to retries times.
Return raw response data
"""
loop = asyncio.get_running_loop()
response_future = loop.create_future()
transport, _ = await loop.create_datagram_endpoint(
lambda: UdpInverterProtocol(response_future, self, timeout, retries),
remote_addr=(host, GOODWE_UDP_PORT),
)
try:
await response_future
result = response_future.result()
if result is not None:
return result
else:
raise RequestFailedException(
"No response received to '" + self.request.hex() + "' request."
)
except asyncio.CancelledError:
raise RequestFailedException(
"No valid response received to '" + self.request.hex() + "' request."
) from None
finally:
transport.close()
class Aa55ProtocolCommand(ProtocolCommand):
"""
Inverter communication protocol seen mostly on older generations of inverters.
Quite probably it is some variation of the protocol used on RS-485 serial link,
extended/adapted to UDP transport layer.
Each request starts with header of 0xAA, 0x55, then 0xC0, 0x7F (probably some sort of address/command)
followed by actual payload data.
It is suffixed with 2 bytes of plain checksum of header+payload.
Response starts again with 0xAA, 0x55, then 0x7F, 0xC0.
5-6th bytes are some response type, byte 7 is length of the response payload.
The last 2 bytes are again plain checksum of header+payload.
"""
def __init__(self, payload: str, response_type: str):
super().__init__(
bytes.fromhex(
"AA55C07F"
+ payload
+ self._checksum(bytes.fromhex("AA55C07F" + payload)).hex()
),
lambda x: self._validate_response(x, response_type),
)
@staticmethod
def _checksum(data: bytes) -> bytes:
checksum = 0
for each in data:
checksum += each
return checksum.to_bytes(2, byteorder="big", signed=False)
@staticmethod
def _validate_response(data: bytes, response_type: str) -> bool:
"""
Validate the response.
data[0:3] is header
data[4:5] is response type
data[6] is response payload length
data[-2:] is checksum (plain sum of response data incl. header)
"""
if len(data) <= 8 or len(data) != data[6] + 9:
logger.debug("Response has unexpected length: %d, expected %d.", len(data), data[6] + 9)
return False
elif response_type:
data_rt_int = int.from_bytes(data[4:6], byteorder="big", signed=True)
if int(response_type, 16) != data_rt_int:
logger.debug("Response type unexpected: %04x, expected %s.", data_rt_int, response_type)
return False
checksum = 0
for each in data[:-2]:
checksum += each
if checksum != int.from_bytes(data[-2:], byteorder="big", signed=True):
logger.debug("Response checksum does not match.")
return False
return True
class Aa55ReadCommand(Aa55ProtocolCommand):
"""
Inverter modbus READ command for retrieving <count> modbus registers starting at register # <offset>
"""
def __init__(self, offset: int, count: int):
super().__init__("011A03" + "{:04x}".format(offset) + "{:02x}".format(count), "019A")
class Aa55WriteCommand(Aa55ProtocolCommand):
"""
Inverter aa55 WRITE command setting single register # <register> value <value>
"""
def __init__(self, register: int, value: int):
super().__init__("023905" + "{:04x}".format(register) + "01" + "{:04x}".format(value), "02B9")
class Aa55WriteMultiCommand(Aa55ProtocolCommand):
"""
Inverter aa55 WRITE command setting multiple register # <register> value <value>
"""
def __init__(self, offset: int, values: bytes):
super().__init__("02390B" + "{:04x}".format(offset) + "{:02x}".format(len(values)) + values.hex(),
"02B9")
class ModbusProtocolCommand(ProtocolCommand):
"""
Inverter communication protocol seen on newer generation of inverters, based on Modbus
protocol over UDP transport layer.
The modbus communication is rather simple, there are "registers" at specified addresses/offsets,
each represented by 2 bytes. The protocol may query/update individual or range of these registers.
Each register represents some measured value or operational settings.
It's inverter implementation specific which register means what.
Some values may span more registers (i.e. 4bytes measurement value over 2 registers).
Every request usually starts with communication address (usually 0xF7, but can be changed).
Second byte is the modbus command - 0x03 read multiple, 0x06 write single, 0x10 write multiple.
Bytes 3-4 represent the register address (or start of range)
Bytes 5-6 represent the command parameter (range size or actual value for write).
Last 2 bytes of request is the CRC-16 (modbus flavor) of the request.
Responses seem to always start with 0xAA, 0x55, then the comm_addr and modbus command.
(If the command fails, the highest bit of command is set to 1 ?)
For read requests, next byte is response payload length, then the actual payload.
Last 2 bytes of response is again the CRC-16 of the response.
"""
def __init__(self, request: bytes, cmd: int, offset: int, value: int):
super().__init__(
request,
lambda x: validate_modbus_response(x, cmd, offset, value),
)
class ModbusReadCommand(ModbusProtocolCommand):
"""
Inverter modbus READ command for retrieving <count> modbus registers starting at register # <offset>
"""
def __init__(self, comm_addr: int, offset: int, count: int):
super().__init__(
create_modbus_request(comm_addr, MODBUS_READ_CMD, offset, count),
MODBUS_READ_CMD, offset, count)
class ModbusWriteCommand(ModbusProtocolCommand):
"""
Inverter modbus WRITE command setting single modbus register # <register> value <value>
"""
def __init__(self, comm_addr: int, register: int, value: int):
super().__init__(
create_modbus_request(comm_addr, MODBUS_WRITE_CMD, register, value),
MODBUS_WRITE_CMD, register, value)
class ModbusWriteMultiCommand(ModbusProtocolCommand):
"""
Inverter modbus WRITE command setting multiple modbus register # <register> value <value>
"""
def __init__(self, comm_addr: int, offset: int, values: bytes):
super().__init__(
create_modbus_multi_request(comm_addr, MODBUS_WRITE_MULTI_CMD, offset, values),
MODBUS_WRITE_MULTI_CMD, offset, len(values) // 2)