From 702d3d64ab18495551bfc0c6d5cf84894e1b1244 Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 12:24:23 +0200 Subject: [PATCH 01/12] Changed repository for pysolarmanv5 --- custom_components/solarman/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index 3fd8d1b..e2d6ccf 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml","pysolarmanv5"], + "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git"], "version": "1.0.0" } From 3697ccfdc5b2820ab3267fa1a875785eff0524fd Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 12:40:26 +0200 Subject: [PATCH 02/12] manifest updated --- custom_components/solarman/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index e2d6ccf..aa163ee 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git"], + "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@main#pysolarmanv5"], "version": "1.0.0" } From 8eae80dd5cad7cb466c563c2448fb377cfbec729 Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 12:54:37 +0200 Subject: [PATCH 03/12] manifest changed --- custom_components/solarman/manifest.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index aa163ee..b696c78 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,6 +7,9 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@main#pysolarmanv5"], + "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@1b6a18ccd181fb9e22ffd314cfecc309dc8fb9d7#subdirectory=pysolarmanv5"], "version": "1.0.0" } + + + From 9748c6e11a69b607c3f07b36fad774dec97e13eb Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 13:00:37 +0200 Subject: [PATCH 04/12] changed manifest --- custom_components/solarman/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index b696c78..fbaf7c8 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@1b6a18ccd181fb9e22ffd314cfecc309dc8fb9d7#subdirectory=pysolarmanv5"], + "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@1b6a18ccd181fb9e22ffd314cfecc309dc8fb9d7"], "version": "1.0.0" } From 6518b58a573d90b957da4dc9ee93a9729f33958e Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 13:05:25 +0200 Subject: [PATCH 05/12] changed manifest --- custom_components/solarman/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index fbaf7c8..4d5e345 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@1b6a18ccd181fb9e22ffd314cfecc309dc8fb9d7"], + "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@0d8e95bcb3469c6d2e5c63454ddc4dd0a047d41"], "version": "1.0.0" } From 2ef3a895cd42467d87fde9542545021fdea0f65c Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 13:09:18 +0200 Subject: [PATCH 06/12] updated manifest --- custom_components/solarman/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index 4d5e345..c36dc49 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@0d8e95bcb3469c6d2e5c63454ddc4dd0a047d41"], + "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@0d8e95bcb3469c6d2e5c63454ddc4dd0a047d415"], "version": "1.0.0" } From c20eba3ac018f8f43a7650aa1bd0de5844ae34e6 Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 13:29:39 +0200 Subject: [PATCH 07/12] updated manifest --- custom_components/solarman/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index c36dc49..afea9a8 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml","git+https://github.com/Dummy0815/pysolarmanv5.git@0d8e95bcb3469c6d2e5c63454ddc4dd0a047d415"], + "requirements": ["pyyaml","pysolarmanv5 @ git+https://github.com/Dummy0815/pysolarmanv5.git@0d8e95bcb3469c6d2e5c63454ddc4dd0a047d415"], "version": "1.0.0" } From cac6c8f7a92e319a546b3cf6104c11ce588f0b64 Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 14:32:39 +0200 Subject: [PATCH 08/12] include changed pysolarmanv5 --- .gitignore | 4 +- custom_components/solarman/manifest.json | 2 +- custom_components/solarman/pysolarmanv5.py | 763 +++++++++++++++++++++ 3 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 custom_components/solarman/pysolarmanv5.py diff --git a/.gitignore b/.gitignore index b59dea7..c68495c 100644 --- a/.gitignore +++ b/.gitignore @@ -130,4 +130,6 @@ dmypy.json # vscode .vscode/ -.vs/ \ No newline at end of file +.vs/ +custom_components/solarman/localtest__init__.py +custom_components/solarman/local_test_solarman.py diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index afea9a8..6ae4a2d 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml","pysolarmanv5 @ git+https://github.com/Dummy0815/pysolarmanv5.git@0d8e95bcb3469c6d2e5c63454ddc4dd0a047d415"], + "requirements": ["pyyaml", "umodbus"], "version": "1.0.0" } diff --git a/custom_components/solarman/pysolarmanv5.py b/custom_components/solarman/pysolarmanv5.py new file mode 100644 index 0000000..3e7e923 --- /dev/null +++ b/custom_components/solarman/pysolarmanv5.py @@ -0,0 +1,763 @@ +"""pysolarmanv5.py""" +import queue +import struct +import socket +import logging +import selectors +import platform +import time +from threading import Thread, Event +from multiprocessing import Queue +from typing import Any +from random import randrange + +from umodbus.client.serial import rtu + + +_WIN_PLATFORM = True if platform.system() == 'Windows' else False + + +class V5FrameError(Exception): + """V5 Frame Validation Error""" + + pass + + +class NoSocketAvailableError(Exception): + """No Socket Available Error""" + + pass + + +class PySolarmanV5: + """ + The PySolarmanV5 class establishes a TCP connection to a Solarman V5 data + logging stick and exposes methods to send/receive Modbus RTU requests and + responses. + + For more detailed information on the Solarman V5 Protocol, see + :doc:`solarmanv5_protocol` + + :param address: IP address or hostname of data logging stick + :type address: str + :param serial: Serial number of the data logging stick (not inverter!) + :type serial: int + :param port: TCP port to connect to data logging stick, defaults to 8899 + :type port: int, optional + :param mb_slave_id: Inverter Modbus slave ID, defaults to 1 + :type mb_slave_id: int, optional + :param socket_timeout: Socket timeout duration in seconds, defaults to 60 + :type socket_timeout: int, optional + :param v5_error_correction: Enable naive error correction for V5 frames, + defaults to False + :type v5_error_correction: bool, optional + + .. versionadded:: v2.4.0 + + :param logger: Python logging facility + :type logger: Logger, optional + :param socket: TCP Socket connection to data logging stick. If **socket** + argument is provided, **address** argument is unused (however, it is + still required as a positional argument) + :type socket: Socket, optional + :raises NoSocketAvailableError: If no network socket is available + + .. versionadded:: v2.5.0 + + :param auto_reconnect: Activates the auto-reconnect functionality. + PySolarmanV5 will try to keep the connection open. The default is False. + Not compatible with custom sockets. + :type auto_reconnect: Boolean, optional + + .. deprecated:: v2.4.0 + + :param verbose: Enable verbose logging, defaults to False. Use **logger** + instead. For compatibility purposes, **verbose**, if enabled, will + create a logger, and set the logging level to DEBUG. + :type verbose: bool, optional + + Basic example: + >>> from pysolarmanv5 import PySolarmanV5 + >>> modbus = PySolarmanV5("192.168.1.10", 123456789) + >>> print(modbus.read_input_registers(register_addr=33022, quantity=6)) + + See :doc:`examples` directory for further examples. + + """ + + def __init__(self, address, serial, **kwargs): + """Constructor""" + + self.log = kwargs.get("logger", None) + if self.log is None: + logging.basicConfig() + self.log = logging.getLogger(__name__) + + self.address = address + self.serial = serial + + self.port = kwargs.get("port", 8899) + self.mb_slave_id = kwargs.get("mb_slave_id", 1) + self.verbose = kwargs.get("verbose", False) + self.socket_timeout = kwargs.get("socket_timeout", 60) + self.v5_error_correction = kwargs.get("v5_error_correction", False) + self.sequence_number = None + + if self.verbose: + self.log.setLevel("DEBUG") + + self._v5_frame_def() + + self.sock: socket.socket = None # noqa + self._poll: selectors.BaseSelector = None # noqa + self._sock_fd: int = None # noqa + self._auto_reconnect = False + self._data_queue: Queue = None # noqa + self._data_wanted: Event = None # noqa + self._reader_exit: Event = None # noqa + self._reader_thr: Thread = None # noqa + self._socket_setup(kwargs.get("socket"), kwargs.get("auto_reconnect", False)) + + def _v5_frame_def(self): + """Define and construct V5 request frame structure.""" + self.v5_start = bytes.fromhex("A5") + self.v5_length = bytes.fromhex("0000") # placeholder value + self.v5_controlcode = struct.pack("=6 bytes, but valid 5 byte error/exception RTU frames + are possible) + + :param v5_frame: V5 frame + :type v5_frame: bytes + :return: Modbus RTU Frame + :rtype: bytes + :raises nothing (silent parsing the v5_frame) + + """ + + modbus_frame = b"" + frame_len_without_payload_len = 13 + v5_start = int.from_bytes(self.v5_start, byteorder="big") + + self.log.debug("_v5_frame_decoder: Check frame buffer len: %i", len(v5_frame)) + while True: + frame_len = len(v5_frame) + while (frame_len > 0 and v5_frame[0] != v5_start): + v5_frame.pop() + frame_len -= 1 + + if (frame_len < self.v5_header_len): + self.log.debug("_v5_frame_decoder: V5 frame not valid/complete") + return b"" #need to wait for more bytes to be received + + self.log.debug("_v5_frame_decoder: V5 frame : " + str(v5_frame)) + self.log.debug("_v5_frame_decoder: V5 frame (hex): " + v5_frame.hex(" ")) + + if v5_frame[5] != self.sequence_number: + self.log.debug("_v5_frame_decoder: V5 frame contains invalid sequence number %s", v5_frame[5:6]) + v5_frame.pop() + continue + + if v5_frame[7:11] != self.v5_loggerserial: + self.log.debug("_v5_frame_decoder: V5 frame contains incorrect data logger serial number") + v5_frame.pop() + continue + + if v5_frame[3:5] != struct.pack(" None: + """ + Disconnect the socket and set a signal for the reader thread to exit + + :return: None + + """ + self._data_wanted.clear() + self._reader_exit.set() + try: + self.sock.send(b"") + self.sock.close() + except OSError: + pass + self._reader_thr.join(0.5) + self._poll.unregister(self._sock_fd) + + def _send_receive_modbus_frame(self, mb_request_frame): + """Encodes mb_frame, sends/receives v5_frame, decodes response + + :param mb_request_frame: Modbus RTU frame to transmit + :type mb_request_frame: bytes + :return: Modbus RTU frame received + :rtype: bytes + + """ + v5_request_frame = self._v5_frame_encoder(mb_request_frame) + mb_response_frame = self._send_receive_v5_frame_payload(v5_request_frame) + return mb_response_frame + + def _get_modbus_response(self, mb_request_frame): + """Returns mb response values for a given mb_request_frame + + :param mb_request_frame: Modbus RTU frame to parse + :type mb_request_frame: bytes + :return: Modbus RTU decoded values + :rtype: list[int] + + """ + mb_response_frame = self._send_receive_modbus_frame(mb_request_frame) + modbus_values = rtu.parse_response_adu(mb_response_frame, mb_request_frame) + return modbus_values + + def _create_socket(self): + """Creates and returns a socket""" + try: + sock = socket.create_connection( + (self.address, self.port), self.socket_timeout + ) + except OSError: + return None + return sock + + def _socket_setup(self, sock: Any, auto_reconnect: bool): + """Socket setup method""" + if isinstance(sock, socket.socket) or sock is None: + self.sock = sock if sock else self._create_socket() + if self.sock is None: + raise NoSocketAvailableError("No socket available") + if _WIN_PLATFORM: + self._poll = selectors.DefaultSelector() + else: + self._poll = selectors.PollSelector() + self._sock_fd = self.sock.fileno() + self._auto_reconnect = False if sock else auto_reconnect + self._data_queue = Queue(maxsize=1) + self._data_wanted = Event() + self._reader_exit = Event() + self._reader_thr = Thread(target=self._data_receiver, daemon=True) + self._reader_thr.start() + self.log.debug(f"Socket setup completed... {self.sock}") + + @staticmethod + def twos_complement(val, num_bits): + """Calculate 2s Complement + + :param val: Value to calculate + :type val: int + :param num_bits: Number of bits + :type num_bits: int + + :return: 2s Complement value + :rtype: int + + """ + if val < 0: + val = (1 << num_bits) + val + else: + if val & (1 << (num_bits - 1)): + val = val - (1 << num_bits) + return val + + def _format_response(self, modbus_values, **kwargs): + """Formats a list of modbus register values (16 bits each) as a single value + + :param modbus_values: Modbus register values + :type modbus_values: list[int] + :param scale: Scaling factor + :type scale: int + :param signed: Signed value (2s complement) + :type signed: bool + :param bitmask: Bitmask value + :type bitmask: int + :param bitshift: Bitshift value + :type bitshift: int + :return: Formatted register value + :rtype: int + + """ + scale = kwargs.get("scale", 1) + signed = kwargs.get("signed", False) + bitmask = kwargs.get("bitmask", None) + bitshift = kwargs.get("bitshift", None) + response = 0 + num_registers = len(modbus_values) + + for i, j in zip(range(num_registers), range(num_registers - 1, -1, -1)): + response += modbus_values[i] << (j * 16) + if signed: + response = self.twos_complement(response, num_registers * 16) + if scale != 1: + response *= scale + if bitmask is not None: + response &= bitmask + if bitshift is not None: + response >>= bitshift + + return response + + def read_input_registers(self, register_addr, quantity): + """Read input registers from modbus slave (Modbus function code 4) + + :param register_addr: Modbus register start address + :type register_addr: int + :param quantity: Number of registers to query + :type quantity: int + + :return: List containing register values + :rtype: list[int] + + """ + mb_request_frame = rtu.read_input_registers( + self.mb_slave_id, register_addr, quantity + ) + modbus_values = self._get_modbus_response(mb_request_frame) + return modbus_values + + def read_holding_registers(self, register_addr, quantity): + """Read holding registers from modbus slave (Modbus function code 3) + + :param register_addr: Modbus register start address + :type register_addr: int + :param quantity: Number of registers to query + :type quantity: int + + :return: List containing register values + :rtype: list[int] + + """ + mb_request_frame = rtu.read_holding_registers( + self.mb_slave_id, register_addr, quantity + ) + modbus_values = self._get_modbus_response(mb_request_frame) + return modbus_values + + def read_input_register_formatted(self, register_addr, quantity, **kwargs): + """Read input registers from modbus slave and format as single value (Modbus function code 4) + + :param register_addr: Modbus register start address + :type register_addr: int + :param quantity: Number of registers to query + :type quantity: int + :param scale: Scaling factor + :type scale: int + :param signed: Signed value (2s complement) + :type signed: bool + :param bitmask: Bitmask value + :type bitmask: int + :param bitshift: Bitshift value + :type bitshift: int + :return: Formatted register value + :rtype: int + + """ + modbus_values = self.read_input_registers(register_addr, quantity) + value = self._format_response(modbus_values, **kwargs) + return value + + def read_holding_register_formatted(self, register_addr, quantity, **kwargs): + """Read holding registers from modbus slave and format as single value (Modbus function code 3) + + :param register_addr: Modbus register start address + :type register_addr: int + :param quantity: Number of registers to query + :type quantity: int + :param scale: Scaling factor + :type scale: int + :param signed: Signed value (2s complement) + :type signed: bool + :param bitmask: Bitmask value + :type bitmask: int + :param bitshift: Bitshift value + :type bitshift: int + :return: Formatted register value + :rtype: int + + """ + modbus_values = self.read_holding_registers(register_addr, quantity) + value = self._format_response(modbus_values, **kwargs) + return value + + def write_holding_register(self, register_addr, value): + """Write a single holding register to modbus slave (Modbus function code 6) + + :param register_addr: Modbus register address + :type register_addr: int + :param value: value to write + :type value: int + :return: value written + :rtype: int + + """ + mb_request_frame = rtu.write_single_register( + self.mb_slave_id, register_addr, value + ) + value = self._get_modbus_response(mb_request_frame) + return value + + def write_multiple_holding_registers(self, register_addr, values): + """Write list of multiple values to series of holding registers on modbus slave (Modbus function code 16) + + :param register_addr: Modbus register start address + :type register_addr: int + :param values: values to write + :type values: list[int] + :return: values written + :rtype: list[int] + + """ + mb_request_frame = rtu.write_multiple_registers( + self.mb_slave_id, register_addr, values + ) + modbus_values = self._get_modbus_response(mb_request_frame) + return modbus_values + + def read_coils(self, register_addr, quantity): + """Read coils from modbus slave and return list of coil values (Modbus function code 1) + + :param register_addr: Modbus register start address + :type register_addr: int + :param quantity: Number of registers to query + :type quantity: int + :return: register values + :rtype: list[int] + + """ + mb_request_frame = rtu.read_coils(self.mb_slave_id, register_addr, quantity) + modbus_values = self._get_modbus_response(mb_request_frame) + return modbus_values + + def read_discrete_inputs(self, register_addr, quantity): + """Read discrete inputs from modbus slave and return list of input values (Modbus function code 2) + + :param register_addr: Modbus register start address + :type register_addr: int + :param quantity: Number of registers to query + :type quantity: int + :return: register values + :rtype: list[int] + + """ + mb_request_frame = rtu.read_discrete_inputs( + self.mb_slave_id, register_addr, quantity + ) + modbus_values = self._get_modbus_response(mb_request_frame) + return modbus_values + + def write_single_coil(self, register_addr, value): + """Write single coil value to modbus slave (Modbus function code 5) + + :param register_addr: Modbus register start address + :type register_addr: int + :param value: value to write; ``0xFF00`` (On) or ``0x0000`` (Off) + :type value: int + :return: value written + :rtype: int + + """ + mb_request_frame = rtu.write_single_coil(self.mb_slave_id, register_addr, value) + modbus_values = self._get_modbus_response(mb_request_frame) + return modbus_values + + def write_multiple_coils(self, register_addr, values): + """Write multiple coil values to modbus slave (Modbus function code 15) + + :param register_addr: Modbus register start address + :type register_addr: int + :param values: values to write; ``1`` (On) or ``0`` (Off) + :type values: list[int] + :return: values written + :rtype: list[int] + + """ + mb_request_frame = rtu.write_multiple_coils( + self.mb_slave_id, register_addr, values + ) + modbus_values = self._get_modbus_response(mb_request_frame) + return modbus_values + + def masked_write_holding_register(self, register_addr, **kwargs): + """Mask write a single holding register to modbus slave (Modbus function code 22) + + Used to set or clear individual bits within a holding register + + If default values are provided for both ``or_mask`` and ``and_mask``, + the write element of this function is a NOP. + + .. warning:: + This is not implemented as a native Modbus function. It is a software + implementation using a combination of :func:`read_holding_registers() ` + and :func:`write_holding_register() `. + + It is therefore **not atomic**. + + :param register_addr: Modbus register address + :type register_addr: int + :param or_mask: OR mask (set bits), defaults to ``0x0000`` (no change) + :type or_mask: int + :param and_mask: AND mask (clear bits), defaults to ``0xFFFF`` (no change) + :type and_mask: int + :return: value written + :rtype: int + + """ + or_mask = kwargs.get("or_mask", 0x0000) + and_mask = kwargs.get("and_mask", 0xFFFF) + + current_value = self.read_holding_registers(register_addr, 1)[0] + + if (or_mask != 0x0000) or (and_mask != 0xFFFF): + masked_value = current_value + masked_value |= or_mask + masked_value &= and_mask + updated_value = self.write_holding_register(register_addr, masked_value) + return updated_value + return current_value + + def send_raw_modbus_frame(self, mb_request_frame): + """Send raw modbus frame and return modbus response frame + + Wrapper around internal method :func:`_send_receive_modbus_frame() ` + + :param mb_request_frame: Modbus frame + :type mb_request_frame: bytearray + :return: Modbus frame + :rtype: bytearray + + """ + return self._send_receive_modbus_frame(mb_request_frame) + + def send_raw_modbus_frame_parsed(self, mb_request_frame): + """Send raw modbus frame and return parsed modbusresponse list + + Wrapper around internal method :func:`_get_modbus_response() ` + + :param mb_request_frame: Modbus frame + :type mb_request_frame: bytearray + :return: Modbus RTU decoded values + :rtype: list[int] + """ + return self._get_modbus_response(mb_request_frame) From 62b4fdec98dda95ccfb2f2c8de508d559ce65b21 Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 1 Oct 2023 14:40:25 +0200 Subject: [PATCH 09/12] blabal --- .../solarman/{pysolarmanv5.py => pysolarmanv5_local.py} | 0 custom_components/solarman/solarman.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename custom_components/solarman/{pysolarmanv5.py => pysolarmanv5_local.py} (100%) diff --git a/custom_components/solarman/pysolarmanv5.py b/custom_components/solarman/pysolarmanv5_local.py similarity index 100% rename from custom_components/solarman/pysolarmanv5.py rename to custom_components/solarman/pysolarmanv5_local.py diff --git a/custom_components/solarman/solarman.py b/custom_components/solarman/solarman.py index 8f74b77..75fd2f1 100644 --- a/custom_components/solarman/solarman.py +++ b/custom_components/solarman/solarman.py @@ -6,7 +6,7 @@ from datetime import datetime from .parser import ParameterParser from .const import * -from pysolarmanv5 import PySolarmanV5 +from .pysolarmanv5_local import PySolarmanV5 log = logging.getLogger(__name__) From 5e7fe71fba21db033e3b0a0090ee6da1190fee51 Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 10 Mar 2024 13:22:44 +0100 Subject: [PATCH 10/12] Service: write_holding_register/write_holding_registers allow selecting device Service: add read_holding_register/read_holding_registers Log only first failed connection break as 'warning', susquence tries to connect only as 'debug'. Reduced Log-File flooding as inverters are usally down >= 12 hours a day --- custom_components/solarman/manifest.json | 2 +- .../solarman/pysolarmanv5_local.py | 27 +-- custom_components/solarman/sensor.py | 14 +- custom_components/solarman/services.py | 166 ++++++++++++++++-- custom_components/solarman/services.yaml | 84 ++++++++- custom_components/solarman/solarman.py | 166 ++++++++++++------ 6 files changed, 365 insertions(+), 94 deletions(-) diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index 6ae4a2d..acdf466 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://github.com/StephanJoubert/home_assistant_solarman/blob/main/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", - "requirements": ["pyyaml", "umodbus"], + "requirements": ["pyyaml","pysolarmanv5"], "version": "1.0.0" } diff --git a/custom_components/solarman/pysolarmanv5_local.py b/custom_components/solarman/pysolarmanv5_local.py index 3e7e923..7843b51 100644 --- a/custom_components/solarman/pysolarmanv5_local.py +++ b/custom_components/solarman/pysolarmanv5_local.py @@ -132,7 +132,9 @@ def _v5_frame_def(self): self.v5_offsettime = bytes.fromhex("00000000") self.v5_checksum = bytes.fromhex("00") # placeholder value self.v5_end = bytes.fromhex("15") - self.v5_header_len = 12 # v5_start(1) + v5_length(2) + v5_controlcode(2) + v5_serial(2) + v5_loggerserial(4) + v5_frametype(1) + self.v5_header_len = 11 # v5_start(1) + v5_length(2) + v5_controlcode(2) + v5_serial(2) + v5_loggerserial(4) + self.v5_frame_len_without_payload = self.v5_header_len + 2 #Header + v5_checksum(1) + v5_end(1) + self.v5_payloadheader_len = 14 # frametype(1) + status(1) + totalWorkingTime(4) + powerOnTime(4) + offsetTime(4) @staticmethod def _calculate_v5_frame_checksum(frame, length): @@ -235,7 +237,6 @@ def _v5_frame_decoder(self, v5_frame): """ modbus_frame = b"" - frame_len_without_payload_len = 13 v5_start = int.from_bytes(self.v5_start, byteorder="big") self.log.debug("_v5_frame_decoder: Check frame buffer len: %i", len(v5_frame)) @@ -273,27 +274,27 @@ def _v5_frame_decoder(self, v5_frame): continue (payload_len,) = struct.unpack(" create RTU error frame") + modbus_frame = b'\x01\x80\x02\xC0\x01' + else: + modbus_frame = v5_frame[self.v5_header_len + self.v5_payloadheader_len : self.v5_frame_len_without_payload + payload_len - 2] + + self.log.debug("_v5_frame_decoder: V5 frame found (hex): " + modbus_frame.hex(" ")) break return modbus_frame diff --git a/custom_components/solarman/sensor.py b/custom_components/solarman/sensor.py index 10e931d..e229363 100644 --- a/custom_components/solarman/sensor.py +++ b/custom_components/solarman/sensor.py @@ -38,7 +38,7 @@ def _do_setup_platform(hass: HomeAssistant, config, async_add_entities : AddEnti inverter_sn = config.get(CONF_INVERTER_SERIAL) if inverter_sn == 0: inverter_sn = _inverter_scanner.get_serialno() - + inverter_mb_slaveid = config.get(CONF_INVERTER_MB_SLAVEID) if not inverter_mb_slaveid: inverter_mb_slaveid = DEFAULT_INVERTER_MB_SLAVEID @@ -70,10 +70,10 @@ def _do_setup_platform(hass: HomeAssistant, config, async_add_entities : AddEnti _LOGGER.debug(hass_sensors) async_add_entities(hass_sensors) - # Register the services with home assistant. - register_services (hass, inverter) - - + # Register the services with home assistant. + register_services (hass) + + @@ -156,6 +156,10 @@ def unique_id(self): def state(self): # Return the state of the sensor. return self.p_state + + def inverter(self): + # Return the inverter of the sensor. """ + return self.inverter def update(self): self.p_state = getattr(self.inverter, self._field_name, None) diff --git a/custom_components/solarman/services.py b/custom_components/solarman/services.py index db656f1..f27cdfb 100644 --- a/custom_components/solarman/services.py +++ b/custom_components/solarman/services.py @@ -1,25 +1,47 @@ -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.helpers import config_validation as cv, entity_registry, entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.exceptions import ServiceValidationError import voluptuous as vol from .const import * from .solarman import Inverter +import logging +log = logging.getLogger(__name__) -SERVICE_WRITE_REGISTER = 'write_holding_register' -SERVICE_WRITE_MULTIPLE_REGISTERS = 'write_multiple_holding_registers' +SERVICE_READ_HOLDING_REGISTER = 'read_holding_register' +SERVICE_READ_MULTIPLE_HOLDING_REGISTERS = 'read_multiple_holding_registers' +SERVICE_WRITE_HOLDING_REGISTER = 'write_holding_register' +SERVICE_WRITE_MULTIPLE_HOLDING_REGISTERS = 'write_multiple_holding_registers' +PARAM_DEVICE = 'device' PARAM_REGISTER = 'register' -PARAM_VALUE = 'value' -PARAM_VALUES = 'values' - +PARAM_COUNT = 'count' +PARAM_VALUE = 'value' +PARAM_VALUES = 'values' # Register the services one can invoke on the inverter. # Apart from this, it also need to be defined in the file # services.yaml for the Home Assistant UI in "Developer Tools" +SERVICE_READ_REGISTER_SCHEMA = vol.Schema( + { + vol.Required(PARAM_DEVICE): vol.All(vol.Coerce(str)), + vol.Required(PARAM_REGISTER): vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) + } +) + +SERVICE_READ_MULTIPLE_REGISTERS_SCHEMA = vol.Schema( + { + vol.Required(PARAM_DEVICE): vol.All(vol.Coerce(str)), + vol.Required(PARAM_REGISTER): vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)), + vol.Required(PARAM_COUNT): vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) + } +) SERVICE_WRITE_REGISTER_SCHEMA = vol.Schema( { + vol.Required(PARAM_DEVICE): vol.All(vol.Coerce(str)), vol.Required(PARAM_REGISTER): vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)), vol.Required(PARAM_VALUE): vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)), } @@ -27,30 +49,142 @@ SERVICE_WRITE_MULTIPLE_REGISTERS_SCHEMA = vol.Schema( { + vol.Required(PARAM_DEVICE): vol.All(vol.Coerce(str)), vol.Required(PARAM_REGISTER): vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)), vol.Required(PARAM_VALUES): vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(min=0, max=65535))]), } ) -def register_services (hass: HomeAssistant, inverter: Inverter ): +def register_services (hass: HomeAssistant ): + + def getInverter(device_id): + inverter: Inverter | None + entity_comp: EntityComponent[entity.Entity] | None + registry = entity_registry.async_get(hass) + entries = entity_registry.async_entries_for_device(registry, device_id) + for entity_reg in entries: + entity_id = entity_reg.entity_id + domain = entity_id.partition(".")[0] + entity_comp = hass.data.get("entity_components", {}).get(domain) + if entity_comp is None: + log.info(f'read_holding_register: Component for {entity_id} not loaded') + continue + + if (entity_obj := entity_comp.get_entity(entity_id)) is None: + log.info(f'read_holding_register: Entity {entity_id} not found') + continue + + if (inverter := entity_obj.inverter) is None: + log.info(f'read_holding_register: Entity {entity_id} has no inverter') + continue + + break + + return inverter + + + async def read_holding_register(call) -> int: + if (inverter := getInverter(call.data.get(PARAM_DEVICE))) is None: + raise ServiceValidationError( + "No communication interface for device found", + translation_domain=DOMAIN, + translation_key="no_interface_found" + ) + + try: + response = inverter.service_read_holding_register( register=call.data.get(PARAM_REGISTER) ) + except Exception as e: + raise ServiceValidationError( + e, + translation_domain=DOMAIN, + translation_key="call_failed" + ) + + result = {call.data.get(PARAM_REGISTER): response[0]} + return result + + async def read_multiple_holding_registers(call) -> int: + if (inverter := getInverter(call.data.get(PARAM_DEVICE))) is None: + raise ServiceValidationError( + "No communication interface for device found", + translation_domain=DOMAIN, + translation_key="no_interface_found" + ) + + try: + response = inverter.service_read_multiple_holding_registers( + register=call.data.get(PARAM_REGISTER), + count=call.data.get(PARAM_COUNT) ) + except Exception as e: + raise ServiceValidationError( + e, + translation_domain=DOMAIN, + translation_key="call_failed" + ) + + result = {} + register=call.data.get(PARAM_REGISTER) + for i in range(0,call.data.get(PARAM_COUNT)): + result[register+i] = response[i] + return result async def write_holding_register(call) -> None: - inverter.service_write_holding_register( - register=call.data.get(PARAM_REGISTER), - value=call.data.get(PARAM_VALUE)) + log.debug(f'write_holding_register: call={call}') + if (inverter := getInverter(call.data.get(PARAM_DEVICE))) is None: + raise ServiceValidationError( + "No communication interface for device found", + translation_domain=DOMAIN, + translation_key="no_interface_found", + ) + + try: + inverter.service_write_holding_register( + register=call.data.get(PARAM_REGISTER), + value=call.data.get(PARAM_VALUE)) + except Exception as e: + raise ServiceValidationError( + e, + translation_domain=DOMAIN, + translation_key="call_failed" + ) + return async def write_multiple_holding_registers(call) -> None: - inverter.service_write_multiple_holding_registers( - register=call.data.get(PARAM_REGISTER), - values=call.data.get(PARAM_VALUES)) + log.debug(f'write_holding_register: call={call}') + if (inverter := getInverter(call.data.get(PARAM_DEVICE))) is None: + raise ServiceValidationError( + "No communication interface for device found", + translation_domain=DOMAIN, + translation_key="no_interface_found", + ) + + try: + inverter.service_write_multiple_holding_registers( + register=call.data.get(PARAM_REGISTER), + values=call.data.get(PARAM_VALUES)) + except Exception as e: + raise ServiceValidationError( + e, + translation_domain=DOMAIN, + translation_key="call_failed" + ) + return hass.services.async_register( - DOMAIN, SERVICE_WRITE_REGISTER, write_holding_register, schema=SERVICE_WRITE_REGISTER_SCHEMA + DOMAIN, SERVICE_READ_HOLDING_REGISTER, read_holding_register, schema=SERVICE_READ_REGISTER_SCHEMA, supports_response=SupportsResponse.OPTIONAL + ) + + hass.services.async_register( + DOMAIN, SERVICE_READ_MULTIPLE_HOLDING_REGISTERS, read_multiple_holding_registers, schema=SERVICE_READ_MULTIPLE_REGISTERS_SCHEMA, supports_response=SupportsResponse.OPTIONAL + ) + + hass.services.async_register( + DOMAIN, SERVICE_WRITE_HOLDING_REGISTER, write_holding_register, schema=SERVICE_WRITE_REGISTER_SCHEMA ) hass.services.async_register( - DOMAIN, SERVICE_WRITE_MULTIPLE_REGISTERS, write_multiple_holding_registers, schema=SERVICE_WRITE_MULTIPLE_REGISTERS_SCHEMA + DOMAIN, SERVICE_WRITE_MULTIPLE_HOLDING_REGISTERS, write_multiple_holding_registers, schema=SERVICE_WRITE_MULTIPLE_REGISTERS_SCHEMA ) return diff --git a/custom_components/solarman/services.yaml b/custom_components/solarman/services.yaml index 5ec2116..c58bfcc 100644 --- a/custom_components/solarman/services.yaml +++ b/custom_components/solarman/services.yaml @@ -1,8 +1,77 @@ +read_holding_register: + name: Read Holding Register (Modbus Function Code 3) + description: Read a single register value + + fields: + device: + name: Device + description: The Device + example: "inverter_roof" + required: true + selector: + device: + filter: + - integration: solarman + register: + name: Register + description: Modbus register address + example: "16384" + required: true + selector: + number: + min: 0 + max: 65535 + mode: box + +read_multiple_holding_registers: + name: Read Multiple Holding Registers (Modbus Function Code 3) + description: Read values from multiple consecutive registers at once. + + fields: + device: + name: Device + description: The Device + example: "inverter_roof" + required: true + selector: + device: + filter: + - integration: solarman + register: + name: Register + description: Modbus register address + example: "16384" + required: true + selector: + number: + min: 0 + max: 65535 + mode: box + count: + name: Count + description: Count of registers to read + example: "4" + required: true + selector: + number: + min: 1 + max: 65535 + mode: box + write_holding_register: name: Write Holding Register (Modbus Function Code 6) - description: NOTE USE WITH CARE! + description: NOTE USE WITH CARE! (Some devices might not accept Code 6 in this case try to use 'Write Multiple Holding Registers') fields: + device: + name: Device + description: The Device + example: "inverter_roof" + required: true + selector: + device: + filter: + - integration: solarman register: name: Register description: Modbus register address @@ -13,7 +82,6 @@ write_holding_register: min: 0 max: 65535 mode: box - value: name: Values description: Value to write @@ -25,9 +93,18 @@ write_holding_register: write_multiple_holding_registers: name: Write Multiple Holding Registers (Modbus Function Code 16) - description: NOTE USE WITH CARE! + description: NOTE USE WITH CARE! (Some devices might not accept Code 16 in this case try to use 'Write Holding Register') fields: + device: + name: Device + description: The Device + example: "inverter_roof" + required: true + selector: + device: + filter: + - integration: solarman register: name: Register description: Modbus register address @@ -38,7 +115,6 @@ write_multiple_holding_registers: min: 0 max: 65535 mode: box - values: name: Values description: Values to write diff --git a/custom_components/solarman/solarman.py b/custom_components/solarman/solarman.py index f6160b4..e3cd7ef 100644 --- a/custom_components/solarman/solarman.py +++ b/custom_components/solarman/solarman.py @@ -1,4 +1,5 @@ import socket +import threading import yaml import logging import struct @@ -6,7 +7,7 @@ from datetime import datetime from .parser import ParameterParser from .const import * -from .pysolarmanv5_local import PySolarmanV5 +from pysolarmanv5 import PySolarmanV5 log = logging.getLogger(__name__) @@ -22,15 +23,23 @@ def __init__(self, path, serial, host, port, mb_slaveid, lookup_file): self._port = port self._mb_slaveid = mb_slaveid self._current_val = None - self.status_connection = "Disconnected" + self._status_connection = -1 self.status_lastUpdate = "N/A" self.lookup_file = lookup_file + self.lock = threading.Lock() + if not self.lookup_file or lookup_file == 'parameters.yaml': self.lookup_file = 'deye_hybrid.yaml' with open(self.path + self.lookup_file) as f: self.parameter_definition = yaml.full_load(f) + @property + def status_connection(self): + return 'Connected' if self._status_connection == 1 else 'Diconnected' + + def is_connected_to_server(self): + return self._modbus != None def connect_to_server(self): if self._modbus: @@ -68,51 +77,51 @@ def get_statistics(self): requests = self.parameter_definition['requests'] log.debug(f"Starting to query for [{len(requests)}] ranges...") - try: - - for request in requests: - start = request['start'] - end = request['end'] - mb_fc = request['mb_functioncode'] - range_string = f"{start}-{end} (0x{start:04X}-0x{end:04X})" - log.debug(f"Querying [{range_string}]...") - - attempts_left = QUERY_RETRY_ATTEMPTS - while attempts_left > 0: - attempts_left -= 1 - try: - self.connect_to_server() - self.send_request(params, start, end, mb_fc) - result = 1 - except Exception as e: - result = 0 - log.warning(f"Querying [{range_string}] failed with exception [{type(e).__name__}: {e}]") - self.disconnect_from_server() + with self.lock: + try: + isConnected = self._status_connection == 1 + for request in requests: + start = request['start'] + end = request['end'] + mb_fc = request['mb_functioncode'] + log.debug(f"Querying [{start} - {end}]...") + + attempts_left = QUERY_RETRY_ATTEMPTS + while attempts_left > 0: + attempts_left -= 1 + try: + self.connect_to_server() + self.send_request(params, start, end, mb_fc) + result = 1 + except Exception as e: + result = 0 + log.log((logging.WARNING if isConnected else logging.DEBUG), f"Querying [{start} - {end}] failed with exception [{type(e).__name__}: {e}]") + self.disconnect_from_server() + if result == 0: + log.log((logging.WARNING if isConnected else logging.DEBUG), f"Querying [{start} - {end}] failed, [{attempts_left}] retry attempts left") + else: + log.debug(f"Querying [{start} - {end}] succeeded") + break if result == 0: - log.warning(f"Querying [{range_string}] failed, [{attempts_left}] retry attempts left") - else: - log.debug(f"Querying [{range_string}] succeeded") + log.log((logging.WARNING if isConnected else logging.DEBUG), f"Querying registers [{start} - {end}] failed, aborting.") break - if result == 0: - log.warning(f"Querying registers [{range_string}] failed, aborting.") - break - - if result == 1: - log.debug(f"All queries succeeded, exposing updated values.") - self.status_lastUpdate = datetime.now().strftime("%m/%d/%Y, %H:%M:%S") - self.status_connection = "Connected" - self._current_val = params.get_result() - else: - self.status_connection = "Disconnected" + + if result == 1: + log.debug(f"All queries succeeded, exposing updated values.") + self.status_lastUpdate = datetime.now().strftime("%m/%d/%Y, %H:%M:%S") + self._status_connection = 1 + self._current_val = params.get_result() + else: + self._status_connection = 0 + # Clear cached previous results to not report stale and incorrect data + self._current_val = {} + self.disconnect_from_server() + except Exception as e: + log.warning(f"Querying inverter {self._serial} at {self._host}:{self._port} failed on connection start with exception [{type(e).__name__}: {e}]") + self._status_connection = 0 # Clear cached previous results to not report stale and incorrect data self._current_val = {} self.disconnect_from_server() - except Exception as e: - log.warning(f"Querying inverter {self._serial} at {self._host}:{self._port} failed on connection start with exception [{type(e).__name__}: {e}]") - self.status_connection = "Disconnected" - # Clear cached previous results to not report stale and incorrect data - self._current_val = {} - self.disconnect_from_server() def get_current_val(self): return self._current_val @@ -121,24 +130,71 @@ def get_sensors(self): params = ParameterParser(self.parameter_definition) return params.get_sensors () -# Service calls + # Service calls + def service_read_holding_register(self, register): + log.debug(f'Service Call: read_holding_register : [{register}]') + + with self.lock: + try: + wasConnected = self.is_connected_to_server() + self.connect_to_server() + response = self._modbus.read_holding_registers(register, 1) + log.info(f'Service Call: read_holding_registers : [{register}] value [{response}]') + if (not wasConnected): + self.disconnect_from_server() + except Exception as e: + log.warning(f"Service Call: read_holding_registers : [{register}] failed with exception [{type(e).__name__}: {e}]") + self.disconnect_from_server() + raise e + + return response + + def service_read_multiple_holding_registers(self, register, count): + log.debug(f'Service Call: read_holding_register : [{register}], count : {count}') + + with self.lock: + try: + wasConnected = self.is_connected_to_server() + self.connect_to_server() + response = self._modbus.read_holding_registers(register, count) + log.info(f'Service Call: read_holding_registers : [{register}] value [{response}]') + if (not wasConnected): + self.disconnect_from_server() + except Exception as e: + log.warning(f"Service Call: read_holding_registers : [{register}] failed with exception [{type(e).__name__}: {e}]") + self.disconnect_from_server() + raise e + + return response + + def service_write_holding_register(self, register, value): log.debug(f'Service Call: write_holding_register : [{register}], value : [{value}]') - try: - self.connect_to_server() - self._modbus.write_holding_register(register, value) - except Exception as e: - log.warning(f"Service Call: write_holding_register : [{register}], value : [{value}] failed with exception [{type(e).__name__}: {e}]") - self.disconnect_from_server() + with self.lock: + try: + wasConnected = self.is_connected_to_server() + self.connect_to_server() + self._modbus.write_holding_register(register, value) + if (not wasConnected): + self.disconnect_from_server() + except Exception as e: + log.warning(f"Service Call: write_holding_register : [{register}], value : [{value}] failed with exception [{type(e).__name__}: {e}]") + self.disconnect_from_server() + raise e return def service_write_multiple_holding_registers(self, register, values): log.debug(f'Service Call: write_multiple_holding_registers: [{register}], values : [{values}]') - try: - self.connect_to_server() - self._modbus.write_multiple_holding_registers(register, values) - except Exception as e: - log.warning(f"Service Call: write_multiple_holding_registers: [{register}], values : [{values}] failed with exception [{type(e).__name__}: {e}]") - self.disconnect_from_server() + with self.lock: + try: + wasConnected = self.is_connected_to_server() + self.connect_to_server() + self._modbus.write_multiple_holding_registers(register, values) + if (not wasConnected): + self.disconnect_from_server() + except Exception as e: + log.warning(f"Service Call: write_multiple_holding_registers: [{register}], values : [{values}] failed with exception [{type(e).__name__}: {e}]") + self.disconnect_from_server() + raise e return From 361d0162a6dd3f40b5e9f86bc0630c1e29c3ced0 Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 10 Mar 2024 14:36:24 +0100 Subject: [PATCH 11/12] Removed DEYE-Inverter fixes --- .gitignore | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.gitignore b/.gitignore index c68495c..b6e4761 100644 --- a/.gitignore +++ b/.gitignore @@ -127,9 +127,3 @@ dmypy.json # Pyre type checker .pyre/ - -# vscode -.vscode/ -.vs/ -custom_components/solarman/localtest__init__.py -custom_components/solarman/local_test_solarman.py From 4e83620365f43cbc3347f4ad976179437799ca36 Mon Sep 17 00:00:00 2001 From: Dummy0815 Date: Sun, 10 Mar 2024 15:28:29 +0100 Subject: [PATCH 12/12] Removed DEYE-Inverter fixes --- .gitignore | 4 + custom_components/solarman/manifest.json | 5 +- .../solarman/pysolarmanv5_local.py | 764 ------------------ 3 files changed, 5 insertions(+), 768 deletions(-) delete mode 100644 custom_components/solarman/pysolarmanv5_local.py diff --git a/.gitignore b/.gitignore index b6e4761..b59dea7 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + +# vscode +.vscode/ +.vs/ \ No newline at end of file diff --git a/custom_components/solarman/manifest.json b/custom_components/solarman/manifest.json index acdf466..644128e 100644 --- a/custom_components/solarman/manifest.json +++ b/custom_components/solarman/manifest.json @@ -9,7 +9,4 @@ "issue_tracker": "https://github.com/StephanJoubert/home_assistant_solarman/issues", "requirements": ["pyyaml","pysolarmanv5"], "version": "1.0.0" -} - - - +} \ No newline at end of file diff --git a/custom_components/solarman/pysolarmanv5_local.py b/custom_components/solarman/pysolarmanv5_local.py deleted file mode 100644 index 7843b51..0000000 --- a/custom_components/solarman/pysolarmanv5_local.py +++ /dev/null @@ -1,764 +0,0 @@ -"""pysolarmanv5.py""" -import queue -import struct -import socket -import logging -import selectors -import platform -import time -from threading import Thread, Event -from multiprocessing import Queue -from typing import Any -from random import randrange - -from umodbus.client.serial import rtu - - -_WIN_PLATFORM = True if platform.system() == 'Windows' else False - - -class V5FrameError(Exception): - """V5 Frame Validation Error""" - - pass - - -class NoSocketAvailableError(Exception): - """No Socket Available Error""" - - pass - - -class PySolarmanV5: - """ - The PySolarmanV5 class establishes a TCP connection to a Solarman V5 data - logging stick and exposes methods to send/receive Modbus RTU requests and - responses. - - For more detailed information on the Solarman V5 Protocol, see - :doc:`solarmanv5_protocol` - - :param address: IP address or hostname of data logging stick - :type address: str - :param serial: Serial number of the data logging stick (not inverter!) - :type serial: int - :param port: TCP port to connect to data logging stick, defaults to 8899 - :type port: int, optional - :param mb_slave_id: Inverter Modbus slave ID, defaults to 1 - :type mb_slave_id: int, optional - :param socket_timeout: Socket timeout duration in seconds, defaults to 60 - :type socket_timeout: int, optional - :param v5_error_correction: Enable naive error correction for V5 frames, - defaults to False - :type v5_error_correction: bool, optional - - .. versionadded:: v2.4.0 - - :param logger: Python logging facility - :type logger: Logger, optional - :param socket: TCP Socket connection to data logging stick. If **socket** - argument is provided, **address** argument is unused (however, it is - still required as a positional argument) - :type socket: Socket, optional - :raises NoSocketAvailableError: If no network socket is available - - .. versionadded:: v2.5.0 - - :param auto_reconnect: Activates the auto-reconnect functionality. - PySolarmanV5 will try to keep the connection open. The default is False. - Not compatible with custom sockets. - :type auto_reconnect: Boolean, optional - - .. deprecated:: v2.4.0 - - :param verbose: Enable verbose logging, defaults to False. Use **logger** - instead. For compatibility purposes, **verbose**, if enabled, will - create a logger, and set the logging level to DEBUG. - :type verbose: bool, optional - - Basic example: - >>> from pysolarmanv5 import PySolarmanV5 - >>> modbus = PySolarmanV5("192.168.1.10", 123456789) - >>> print(modbus.read_input_registers(register_addr=33022, quantity=6)) - - See :doc:`examples` directory for further examples. - - """ - - def __init__(self, address, serial, **kwargs): - """Constructor""" - - self.log = kwargs.get("logger", None) - if self.log is None: - logging.basicConfig() - self.log = logging.getLogger(__name__) - - self.address = address - self.serial = serial - - self.port = kwargs.get("port", 8899) - self.mb_slave_id = kwargs.get("mb_slave_id", 1) - self.verbose = kwargs.get("verbose", False) - self.socket_timeout = kwargs.get("socket_timeout", 60) - self.v5_error_correction = kwargs.get("v5_error_correction", False) - self.sequence_number = None - - if self.verbose: - self.log.setLevel("DEBUG") - - self._v5_frame_def() - - self.sock: socket.socket = None # noqa - self._poll: selectors.BaseSelector = None # noqa - self._sock_fd: int = None # noqa - self._auto_reconnect = False - self._data_queue: Queue = None # noqa - self._data_wanted: Event = None # noqa - self._reader_exit: Event = None # noqa - self._reader_thr: Thread = None # noqa - self._socket_setup(kwargs.get("socket"), kwargs.get("auto_reconnect", False)) - - def _v5_frame_def(self): - """Define and construct V5 request frame structure.""" - self.v5_start = bytes.fromhex("A5") - self.v5_length = bytes.fromhex("0000") # placeholder value - self.v5_controlcode = struct.pack("=6 bytes, but valid 5 byte error/exception RTU frames - are possible) - - :param v5_frame: V5 frame - :type v5_frame: bytes - :return: Modbus RTU Frame - :rtype: bytes - :raises nothing (silent parsing the v5_frame) - - """ - - modbus_frame = b"" - v5_start = int.from_bytes(self.v5_start, byteorder="big") - - self.log.debug("_v5_frame_decoder: Check frame buffer len: %i", len(v5_frame)) - while True: - frame_len = len(v5_frame) - while (frame_len > 0 and v5_frame[0] != v5_start): - v5_frame.pop() - frame_len -= 1 - - if (frame_len < self.v5_header_len): - self.log.debug("_v5_frame_decoder: V5 frame not valid/complete") - return b"" #need to wait for more bytes to be received - - self.log.debug("_v5_frame_decoder: V5 frame : " + str(v5_frame)) - self.log.debug("_v5_frame_decoder: V5 frame (hex): " + v5_frame.hex(" ")) - - if v5_frame[5] != self.sequence_number: - self.log.debug("_v5_frame_decoder: V5 frame contains invalid sequence number %s", v5_frame[5:6]) - v5_frame.pop() - continue - - if v5_frame[7:11] != self.v5_loggerserial: - self.log.debug("_v5_frame_decoder: V5 frame contains incorrect data logger serial number") - v5_frame.pop() - continue - - if v5_frame[3:5] != struct.pack(" create RTU error frame") - modbus_frame = b'\x01\x80\x02\xC0\x01' - else: - modbus_frame = v5_frame[self.v5_header_len + self.v5_payloadheader_len : self.v5_frame_len_without_payload + payload_len - 2] - - self.log.debug("_v5_frame_decoder: V5 frame found (hex): " + modbus_frame.hex(" ")) - break - - return modbus_frame - - def _send_receive_v5_frame_payload(self, data_logging_stick_frame): - """Send v5 frame to the data logger and receive response payload - - :param data_logging_stick_frame: V5 frame to transmit - :type data_logging_stick_frame: bytes - :return: V5 frame received - :rtype: bytes - - """ - self.log.debug("SENT: " + data_logging_stick_frame.hex(" ")) - if not self._reader_thr.is_alive(): - raise NoSocketAvailableError("Connection already closed.") - self.sock.sendall(data_logging_stick_frame) - deadline = time.monotonic() + self.socket_timeout - self._data_wanted.set() - try: - v5_response = bytearray() - while True: - v5_response_actual = self._data_queue.get(timeout=self.socket_timeout) - if v5_response_actual == b"": - raise NoSocketAvailableError("Connection closed on read") - v5_response.extend(v5_response_actual) - v5_frame = self._v5_frame_decoder(v5_response) - if (v5_frame != b""): - break - if ((deadline - time.monotonic()) <= 0): - raise TimeoutError - - self._data_wanted.clear() - except TimeoutError: - raise - - self.log.debug("RECD: " + v5_frame.hex(" ")) - return v5_frame - - def _data_receiver(self): - self._poll.register(self.sock.fileno(), selectors.EVENT_READ) - while True: - events = self._poll.select(.500) - if self._reader_exit.is_set(): - return - for event in events: - # We are registered only for inbound data on a single socket, - # so there is no need to check the (fileno, mask) tuples - try: - data = self.sock.recv(1024) - except ConnectionResetError: - self.log.debug(f'[{self.serial}] Connection RESET by peer.') - data = b"" - if data == b"": - self.log.debug(f"[POLL] Socket closed. Reader thread exiting.") - if self._data_wanted.is_set(): - try: - self._data_queue.put_nowait(data) - except queue.Full: - pass - self._reconnect() - return - - if self._data_wanted.is_set(): - self._data_queue.put(data, timeout=self.socket_timeout) - else: - self.log.debug("[POLL-DISCARDED] RECD: " + data.hex(" ")) - - def _reconnect(self): - """ - Reconnect to the data logger if needed - """ - if self._reader_thr.is_alive(): - try: - self.sock.send(b"") - self.sock.close() - except OSError: - pass - self._reader_exit.set() - if self._auto_reconnect: - self.log.debug( - f"Auto-Reconnect enabled. Trying to establish a new connection" - ) - self._poll.unregister(self._sock_fd) - self.sock = self._create_socket() - if self.sock: - self._sock_fd = self.sock.fileno() - self._reader_exit.clear() - self._reader_thr = Thread(target=self._data_receiver, daemon=True) - self._reader_thr.start() - self.log.debug(f"Auto-Reconnect successful.") - else: - self.log.debug(f"No socket available! Reconnect failed.") - else: - self.log.debug("Auto-Reconnect inactive.") - - def disconnect(self) -> None: - """ - Disconnect the socket and set a signal for the reader thread to exit - - :return: None - - """ - self._data_wanted.clear() - self._reader_exit.set() - try: - self.sock.send(b"") - self.sock.close() - except OSError: - pass - self._reader_thr.join(0.5) - self._poll.unregister(self._sock_fd) - - def _send_receive_modbus_frame(self, mb_request_frame): - """Encodes mb_frame, sends/receives v5_frame, decodes response - - :param mb_request_frame: Modbus RTU frame to transmit - :type mb_request_frame: bytes - :return: Modbus RTU frame received - :rtype: bytes - - """ - v5_request_frame = self._v5_frame_encoder(mb_request_frame) - mb_response_frame = self._send_receive_v5_frame_payload(v5_request_frame) - return mb_response_frame - - def _get_modbus_response(self, mb_request_frame): - """Returns mb response values for a given mb_request_frame - - :param mb_request_frame: Modbus RTU frame to parse - :type mb_request_frame: bytes - :return: Modbus RTU decoded values - :rtype: list[int] - - """ - mb_response_frame = self._send_receive_modbus_frame(mb_request_frame) - modbus_values = rtu.parse_response_adu(mb_response_frame, mb_request_frame) - return modbus_values - - def _create_socket(self): - """Creates and returns a socket""" - try: - sock = socket.create_connection( - (self.address, self.port), self.socket_timeout - ) - except OSError: - return None - return sock - - def _socket_setup(self, sock: Any, auto_reconnect: bool): - """Socket setup method""" - if isinstance(sock, socket.socket) or sock is None: - self.sock = sock if sock else self._create_socket() - if self.sock is None: - raise NoSocketAvailableError("No socket available") - if _WIN_PLATFORM: - self._poll = selectors.DefaultSelector() - else: - self._poll = selectors.PollSelector() - self._sock_fd = self.sock.fileno() - self._auto_reconnect = False if sock else auto_reconnect - self._data_queue = Queue(maxsize=1) - self._data_wanted = Event() - self._reader_exit = Event() - self._reader_thr = Thread(target=self._data_receiver, daemon=True) - self._reader_thr.start() - self.log.debug(f"Socket setup completed... {self.sock}") - - @staticmethod - def twos_complement(val, num_bits): - """Calculate 2s Complement - - :param val: Value to calculate - :type val: int - :param num_bits: Number of bits - :type num_bits: int - - :return: 2s Complement value - :rtype: int - - """ - if val < 0: - val = (1 << num_bits) + val - else: - if val & (1 << (num_bits - 1)): - val = val - (1 << num_bits) - return val - - def _format_response(self, modbus_values, **kwargs): - """Formats a list of modbus register values (16 bits each) as a single value - - :param modbus_values: Modbus register values - :type modbus_values: list[int] - :param scale: Scaling factor - :type scale: int - :param signed: Signed value (2s complement) - :type signed: bool - :param bitmask: Bitmask value - :type bitmask: int - :param bitshift: Bitshift value - :type bitshift: int - :return: Formatted register value - :rtype: int - - """ - scale = kwargs.get("scale", 1) - signed = kwargs.get("signed", False) - bitmask = kwargs.get("bitmask", None) - bitshift = kwargs.get("bitshift", None) - response = 0 - num_registers = len(modbus_values) - - for i, j in zip(range(num_registers), range(num_registers - 1, -1, -1)): - response += modbus_values[i] << (j * 16) - if signed: - response = self.twos_complement(response, num_registers * 16) - if scale != 1: - response *= scale - if bitmask is not None: - response &= bitmask - if bitshift is not None: - response >>= bitshift - - return response - - def read_input_registers(self, register_addr, quantity): - """Read input registers from modbus slave (Modbus function code 4) - - :param register_addr: Modbus register start address - :type register_addr: int - :param quantity: Number of registers to query - :type quantity: int - - :return: List containing register values - :rtype: list[int] - - """ - mb_request_frame = rtu.read_input_registers( - self.mb_slave_id, register_addr, quantity - ) - modbus_values = self._get_modbus_response(mb_request_frame) - return modbus_values - - def read_holding_registers(self, register_addr, quantity): - """Read holding registers from modbus slave (Modbus function code 3) - - :param register_addr: Modbus register start address - :type register_addr: int - :param quantity: Number of registers to query - :type quantity: int - - :return: List containing register values - :rtype: list[int] - - """ - mb_request_frame = rtu.read_holding_registers( - self.mb_slave_id, register_addr, quantity - ) - modbus_values = self._get_modbus_response(mb_request_frame) - return modbus_values - - def read_input_register_formatted(self, register_addr, quantity, **kwargs): - """Read input registers from modbus slave and format as single value (Modbus function code 4) - - :param register_addr: Modbus register start address - :type register_addr: int - :param quantity: Number of registers to query - :type quantity: int - :param scale: Scaling factor - :type scale: int - :param signed: Signed value (2s complement) - :type signed: bool - :param bitmask: Bitmask value - :type bitmask: int - :param bitshift: Bitshift value - :type bitshift: int - :return: Formatted register value - :rtype: int - - """ - modbus_values = self.read_input_registers(register_addr, quantity) - value = self._format_response(modbus_values, **kwargs) - return value - - def read_holding_register_formatted(self, register_addr, quantity, **kwargs): - """Read holding registers from modbus slave and format as single value (Modbus function code 3) - - :param register_addr: Modbus register start address - :type register_addr: int - :param quantity: Number of registers to query - :type quantity: int - :param scale: Scaling factor - :type scale: int - :param signed: Signed value (2s complement) - :type signed: bool - :param bitmask: Bitmask value - :type bitmask: int - :param bitshift: Bitshift value - :type bitshift: int - :return: Formatted register value - :rtype: int - - """ - modbus_values = self.read_holding_registers(register_addr, quantity) - value = self._format_response(modbus_values, **kwargs) - return value - - def write_holding_register(self, register_addr, value): - """Write a single holding register to modbus slave (Modbus function code 6) - - :param register_addr: Modbus register address - :type register_addr: int - :param value: value to write - :type value: int - :return: value written - :rtype: int - - """ - mb_request_frame = rtu.write_single_register( - self.mb_slave_id, register_addr, value - ) - value = self._get_modbus_response(mb_request_frame) - return value - - def write_multiple_holding_registers(self, register_addr, values): - """Write list of multiple values to series of holding registers on modbus slave (Modbus function code 16) - - :param register_addr: Modbus register start address - :type register_addr: int - :param values: values to write - :type values: list[int] - :return: values written - :rtype: list[int] - - """ - mb_request_frame = rtu.write_multiple_registers( - self.mb_slave_id, register_addr, values - ) - modbus_values = self._get_modbus_response(mb_request_frame) - return modbus_values - - def read_coils(self, register_addr, quantity): - """Read coils from modbus slave and return list of coil values (Modbus function code 1) - - :param register_addr: Modbus register start address - :type register_addr: int - :param quantity: Number of registers to query - :type quantity: int - :return: register values - :rtype: list[int] - - """ - mb_request_frame = rtu.read_coils(self.mb_slave_id, register_addr, quantity) - modbus_values = self._get_modbus_response(mb_request_frame) - return modbus_values - - def read_discrete_inputs(self, register_addr, quantity): - """Read discrete inputs from modbus slave and return list of input values (Modbus function code 2) - - :param register_addr: Modbus register start address - :type register_addr: int - :param quantity: Number of registers to query - :type quantity: int - :return: register values - :rtype: list[int] - - """ - mb_request_frame = rtu.read_discrete_inputs( - self.mb_slave_id, register_addr, quantity - ) - modbus_values = self._get_modbus_response(mb_request_frame) - return modbus_values - - def write_single_coil(self, register_addr, value): - """Write single coil value to modbus slave (Modbus function code 5) - - :param register_addr: Modbus register start address - :type register_addr: int - :param value: value to write; ``0xFF00`` (On) or ``0x0000`` (Off) - :type value: int - :return: value written - :rtype: int - - """ - mb_request_frame = rtu.write_single_coil(self.mb_slave_id, register_addr, value) - modbus_values = self._get_modbus_response(mb_request_frame) - return modbus_values - - def write_multiple_coils(self, register_addr, values): - """Write multiple coil values to modbus slave (Modbus function code 15) - - :param register_addr: Modbus register start address - :type register_addr: int - :param values: values to write; ``1`` (On) or ``0`` (Off) - :type values: list[int] - :return: values written - :rtype: list[int] - - """ - mb_request_frame = rtu.write_multiple_coils( - self.mb_slave_id, register_addr, values - ) - modbus_values = self._get_modbus_response(mb_request_frame) - return modbus_values - - def masked_write_holding_register(self, register_addr, **kwargs): - """Mask write a single holding register to modbus slave (Modbus function code 22) - - Used to set or clear individual bits within a holding register - - If default values are provided for both ``or_mask`` and ``and_mask``, - the write element of this function is a NOP. - - .. warning:: - This is not implemented as a native Modbus function. It is a software - implementation using a combination of :func:`read_holding_registers() ` - and :func:`write_holding_register() `. - - It is therefore **not atomic**. - - :param register_addr: Modbus register address - :type register_addr: int - :param or_mask: OR mask (set bits), defaults to ``0x0000`` (no change) - :type or_mask: int - :param and_mask: AND mask (clear bits), defaults to ``0xFFFF`` (no change) - :type and_mask: int - :return: value written - :rtype: int - - """ - or_mask = kwargs.get("or_mask", 0x0000) - and_mask = kwargs.get("and_mask", 0xFFFF) - - current_value = self.read_holding_registers(register_addr, 1)[0] - - if (or_mask != 0x0000) or (and_mask != 0xFFFF): - masked_value = current_value - masked_value |= or_mask - masked_value &= and_mask - updated_value = self.write_holding_register(register_addr, masked_value) - return updated_value - return current_value - - def send_raw_modbus_frame(self, mb_request_frame): - """Send raw modbus frame and return modbus response frame - - Wrapper around internal method :func:`_send_receive_modbus_frame() ` - - :param mb_request_frame: Modbus frame - :type mb_request_frame: bytearray - :return: Modbus frame - :rtype: bytearray - - """ - return self._send_receive_modbus_frame(mb_request_frame) - - def send_raw_modbus_frame_parsed(self, mb_request_frame): - """Send raw modbus frame and return parsed modbusresponse list - - Wrapper around internal method :func:`_get_modbus_response() ` - - :param mb_request_frame: Modbus frame - :type mb_request_frame: bytearray - :return: Modbus RTU decoded values - :rtype: list[int] - """ - return self._get_modbus_response(mb_request_frame)