Skip to content

Commit

Permalink
refactor: Moved pysolarmanv5 enhancements to submodule
Browse files Browse the repository at this point in the history
  • Loading branch information
davidrapan committed Dec 22, 2024
1 parent f7555b5 commit 7892d13
Show file tree
Hide file tree
Showing 4 changed files with 3 additions and 156 deletions.
140 changes: 2 additions & 138 deletions custom_components/solarman/api.py
Original file line number Diff line number Diff line change
@@ -1,153 +1,17 @@
import time
import errno
import struct
import socket
import logging
import asyncio
import threading
import concurrent.futures

from datetime import datetime

from umodbus.client.tcp import read_coils, read_discrete_inputs, read_holding_registers, read_input_registers, write_single_coil, write_multiple_coils, write_single_register, write_multiple_registers, parse_response_adu

from .const import *
from .common import *
from .provider import *
from .include.pysolarmanv5 import PySolarmanV5Async, NoSocketAvailableError
from .include.pysolarmanv5 import PySolarmanAsync

_LOGGER = logging.getLogger(__name__)

class PySolarmanV5AsyncWrapper(PySolarmanV5Async):
def __init__(self, address, serial, port, mb_slave_id):
super().__init__(address, serial, port = port, mb_slave_id = mb_slave_id, logger = _LOGGER, auto_reconnect = AUTO_RECONNECT, socket_timeout = TIMINGS_SOCKET_TIMEOUT)

@property
def auto_reconnect(self):
return self._needs_reconnect

async def connect(self) -> bool:
if not self.reader_task:
_LOGGER.info(f"[{self.serial}] Connecting to {self.address}:{self.port}")
await super().connect()
return True
return False

async def disconnect(self) -> None:
_LOGGER.info(f"[{self.serial}] Disconnecting from {self.address}:{self.port}")
try:
await super().disconnect()
finally:
self.reader_task = None
self.reader = None
self.writer = None

class PySolarmanAsync(PySolarmanV5AsyncWrapper):
def __init__(self, address, serial, port, mb_slave_id):
super().__init__(address, serial, port, mb_slave_id)
self._passthrough = False

async def _tcp_send_receive_frame(self, mb_request_frame):
return mb_compatibility(await self._send_receive_v5_frame(mb_request_frame), mb_request_frame)

async def _tcp_parse_response_adu(self, mb_request_frame):
return parse_response_adu(await self._tcp_send_receive_frame(mb_request_frame), mb_request_frame)

async def _heartbeat_response(self, request_frame):
v5_frame = bytearray(
self.v5_start
+ struct.pack("<H", 10)
+ CONTROL_CODE.HEARTBEAT_RESPONSE
+ request_frame[5:7]
+ self.v5_loggerserial
+ struct.pack("<H", 0x0100)
+ struct.pack("<I", int(time.time()))
+ struct.pack("<I", 0)
+ self.v5_checksum
+ self.v5_end
)

v5_frame[5] = (v5_frame[5] + 1) & 0xFF

v5_frame[-2] = self._calculate_v5_frame_checksum(v5_frame)

self.log.debug("[%s] V5_HEARTBEAT RESPONSE: %s", self.serial, v5_frame.hex(" "))
try:
self.writer.write(v5_frame)
await self.writer.drain()
except AttributeError as e:
raise NoSocketAvailableError("Connection already closed") from e
except NoSocketAvailableError:
raise
except TimeoutError:
raise
except OSError as e:
if e.errno == errno.EHOSTUNREACH:
raise TimeoutError from e
raise
except Exception as e:
self.log.exception("[%s] Send/Receive error: %s", self.serial, e)
raise

def _received_frame_is_valid(self, frame):
if self._passthrough:
return True
if not frame.startswith(self.v5_start):
self.log.debug("[%s] V5_MISMATCH: %s", self.serial, frame.hex(" "))
return False
if frame[5] != self.sequence_number and is_ethernet_frame(frame):
self.log.debug("[%s] V5_ETHERNET_DETECTED: %s", self.serial, frame.hex(" "))
self._passthrough = True
return True
if frame[5] != self.sequence_number:
self.log.debug("[%s] V5_SEQ_NO_MISMATCH: %s", self.serial, frame.hex(" "))
return False
if frame.startswith(self.v5_start + CONTROL_CODE.HEARTBEAT):
self.log.debug("[%s] V5_HEARTBEAT: %s", self.serial, frame.hex(" "))
asyncio.ensure_future(self._heartbeat_response(frame))
return False
return True

async def read_coils(self, register_addr, quantity):
if not self._passthrough:
return await super().read_coils(register_addr, quantity)
return await self._tcp_parse_response_adu(read_coils(self.mb_slave_id, register_addr, quantity))

async def read_discrete_inputs(self, register_addr, quantity):
if not self._passthrough:
return await super().read_discrete_inputs(register_addr, quantity)
return await self._tcp_parse_response_adu(read_discrete_inputs(self.mb_slave_id, register_addr, quantity))

async def read_input_registers(self, register_addr, quantity):
if not self._passthrough:
return await super().read_input_registers(register_addr, quantity)
return await self._tcp_parse_response_adu(read_input_registers(self.mb_slave_id, register_addr, quantity))

async def read_holding_registers(self, register_addr, quantity):
if not self._passthrough:
return await super().read_holding_registers(register_addr, quantity)
return await self._tcp_parse_response_adu(read_holding_registers(self.mb_slave_id, register_addr, quantity))

async def write_single_coil(self, register_addr, value):
if not self._passthrough:
return await super().write_single_coil(register_addr, value)
return await self._tcp_parse_response_adu(write_single_coil(self.mb_slave_id, register_addr, value))

async def write_multiple_coils(self, register_addr, values):
if not self._passthrough:
return await super().write_multiple_coils(register_addr, values)
return await self._tcp_parse_response_adu(write_multiple_coils(self.mb_slave_id, register_addr, values))

async def write_holding_register(self, register_addr, value):
if not self._passthrough:
return await super().write_holding_register(register_addr, value)
return await self._tcp_parse_response_adu(write_single_register(self.mb_slave_id, register_addr, value))

async def write_multiple_holding_registers(self, register_addr, values):
if not self._passthrough:
return await super().write_multiple_holding_registers(register_addr, values)
return await self._tcp_parse_response_adu(write_multiple_registers(self.mb_slave_id, register_addr, values))

class InverterState():
def __init__(self):
self.updated = datetime.now()
Expand Down Expand Up @@ -187,7 +51,7 @@ async def load(self):
try:
self.endpoint = await EndPointProvider(self.config).discover()
self.profile = ProfileProvider(self.config, self.endpoint)
self.modbus = PySolarmanAsync(*self.endpoint.connection)
self.modbus = PySolarmanAsync(*self.endpoint.connection, _LOGGER, AUTO_RECONNECT, TIMINGS_SOCKET_TIMEOUT)
self.device_info = await self.profile.resolve(self.get)
_LOGGER.debug(self.device_info)
except BaseException as e:
Expand Down
11 changes: 0 additions & 11 deletions custom_components/solarman/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,17 +128,6 @@ def group_when(iterable, predicate):
i += 1
yield iterable[x:size]

def is_ethernet_frame(frame):
if frame[3:5] == CONTROL_CODE.REQUEST and (frame_len := len(frame)):
if frame_len > 9:
return int.from_bytes(frame[5:6], byteorder = "big") == len(frame[6:]) and int.from_bytes(frame[8:9], byteorder = "big") == len(frame[9:])
if frame_len > 6: # [0xa5, 0x17, 0x00, 0x10, 0x45, 0x03, 0x00, 0x98, 0x02]
return int.from_bytes(frame[5:6], byteorder = "big") == len(frame[6:])
return False

def mb_compatibility(response, request):
return response if not 8 <= (l := len(response)) <= 10 else response[:5] + b'\x06' + response[6:] + (request[l:10] if len(request) > 12 else (b'\x00' * (10 - l))) + b'\x00\x01'

def format_exception(e):
return re.sub(r"\s+", " ", f"{type(e).__name__}{f': {e}' if f'{e}' else ''}")

Expand Down
6 changes: 0 additions & 6 deletions custom_components/solarman/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,6 @@

ATTR_ = { CONF_MOD: CONF_MOD, CONF_MPPT: CONF_MPPT, CONF_PHASE: "l" }

CONTROL_CODE = types.SimpleNamespace()
CONTROL_CODE.REQUEST = struct.pack("<H", 0x4510)
CONTROL_CODE.RESPONSE = struct.pack("<H", 0x1510)
CONTROL_CODE.HEARTBEAT = struct.pack("<I", 0x47100001)
CONTROL_CODE.HEARTBEAT_RESPONSE = struct.pack("<H", 0x1710)

AUTO_RECONNECT = True

# Data are requsted in most cases in different invervals:
Expand Down

0 comments on commit 7892d13

Please sign in to comment.