diff --git a/README.md b/README.md index 007866fc..56836feb 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ A python package with which you can read the data from your Omnik Inverter. Keep - Omnik1500TL - Omnik2000TL - Omnik2000TL2 +- Omnik3000TL - Omnik4000TL2 - Ginlong stick (JSON) @@ -68,7 +69,7 @@ if __name__ == "__main__": loop.run_until_complete(main()) ``` -For the **source type** you can choose between: `javascript` (default), `json` and `html`. +For the **source type** you can choose between: `javascript` (default), `json`, `html` and `tcp`. ## Data @@ -85,6 +86,13 @@ You can read the following data with this package: - Day Energy Production (kWh) - Total Energy Production (kWh) +On the `tcp` source type you can also find: + +- Inverter temperature; +- Voltage and current for the DC input strings (up to 3) +- Voltage, current, frequency and power for all AC outputs (also up to 3) +- Total number of runtime hours. + ### Device - Signal Quality (only with JS) diff --git a/omnikinverter/exceptions.py b/omnikinverter/exceptions.py index 18e7591d..1cbd16d5 100644 --- a/omnikinverter/exceptions.py +++ b/omnikinverter/exceptions.py @@ -24,3 +24,11 @@ class OmnikInverterWrongValuesError(OmnikInverterError): shows the same value for both day and total production. """ + + +class OmnikInverterPacketInvalidError(OmnikInverterError): + """Omnik Inverter replied with invalid data. + + This error appears on the "tcp" source when received + data cannot be validated. + """ diff --git a/omnikinverter/models.py b/omnikinverter/models.py index 5c9cfe00..8470e88f 100644 --- a/omnikinverter/models.py +++ b/omnikinverter/models.py @@ -22,6 +22,19 @@ class Inverter: solar_energy_today: float | None solar_energy_total: float | None + # TCP only + solar_hours_total: int | None = None + + temperature: float | None = None + + dc_input_voltage: list[float] | None = None + dc_input_current: list[float] | None = None + + ac_output_voltage: list[float] | None = None + ac_output_current: list[float] | None = None + ac_output_frequency: list[float] | None = None + ac_output_power: list[float] | None = None + @staticmethod def from_json(data: dict[str, Any]) -> Inverter: """Return Inverter object from the Omnik Inverter response. @@ -156,6 +169,25 @@ def get_value(position): solar_energy_total=get_value(7), ) + @staticmethod + def from_tcp(data: dict[str, Any]) -> Inverter: + """Return Inverter object from the Omnik Inverter response. + + Args: + data: The binary data from the Omnik Inverter. + + Returns: + An Inverter object. + """ + + return Inverter( + **data, + solar_rated_power=None, + solar_current_power=sum( + p for p in data["ac_output_power"] if p is not None + ), + ) + @dataclass class Device: diff --git a/omnikinverter/omnikinverter.py b/omnikinverter/omnikinverter.py index f2ee7034..e0d4178e 100644 --- a/omnikinverter/omnikinverter.py +++ b/omnikinverter/omnikinverter.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import socket from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -18,6 +19,7 @@ OmnikInverterError, ) from .models import Device, Inverter +from .tcp_model import TcpParser @dataclass @@ -30,6 +32,8 @@ class OmnikInverter: source_type: str = "javascript" request_timeout: int = 10 session: ClientSession | None = None + serial_number: int | None = None # Optional, only for TCP backend + tcp_port: int = 8899 _close_session: bool = False @@ -116,6 +120,38 @@ async def request( return raw_response.decode("ascii", "ignore") + async def tcp_request(self) -> dict[str, Any]: + """Perform a raw TCP request to the Omnik device. + + Returns: + A Python dictionary (text) with the response from + the Omnik Inverter. + + Raises: + OmnikInverterAuthError: Serial number is required to communicate + with the Omnik Inverter. + OmnikInverterConnectionError: An error occurred while communicating + with the Omnik Inverter. + """ + if self.serial_number is None: + raise OmnikInverterAuthError("serial_number is missing from the request") + + try: + reader, writer = await asyncio.open_connection(self.host, self.tcp_port) + writer.write(TcpParser.create_information_request(self.serial_number)) + await writer.drain() + + raw_msg = await reader.read(1024) + except Exception as exception: + raise OmnikInverterConnectionError( + "Failed to communicate with the Omnik Inverter device over TCP" + ) from exception + finally: + writer.close() + await writer.wait_closed() + + return TcpParser.parse_information_reply(self.serial_number, raw_msg) + async def inverter(self) -> Inverter: """Get values from your Omnik Inverter. @@ -128,14 +164,18 @@ async def inverter(self) -> Inverter: if self.source_type == "html": data = await self.request("status.html") return Inverter.from_html(data) - data = await self.request("js/status.js") - return Inverter.from_js(data) + if self.source_type == "javascript": + data = await self.request("js/status.js") + return Inverter.from_js(data) + if self.source_type == "tcp": + data = await self.tcp_request() + return Inverter.from_tcp(data) async def device(self) -> Device: """Get values from the device. Returns: - A Device data object from the Omnik Inverter. + A Device data object from the Omnik Inverter. None on the "tcp" source_type. """ if self.source_type == "json": data = await self.request("status.json", params={"CMD": "inv_query"}) @@ -143,8 +183,9 @@ async def device(self) -> Device: if self.source_type == "html": data = await self.request("status.html") return Device.from_html(data) - data = await self.request("js/status.js") - return Device.from_js(data) + if self.source_type == "javascript": + data = await self.request("js/status.js") + return Device.from_js(data) async def close(self) -> None: """Close open client session.""" diff --git a/omnikinverter/tcp_model.py b/omnikinverter/tcp_model.py new file mode 100644 index 00000000..2323ecfa --- /dev/null +++ b/omnikinverter/tcp_model.py @@ -0,0 +1,198 @@ +"""Data model and conversions for tcp-based communication.""" +from ctypes import BigEndianStructure, c_char, c_ubyte, c_uint, c_ushort +from typing import Any + +from .exceptions import OmnikInverterPacketInvalidError + +UINT16_MAX = 65535 + + +class _AcOutput(BigEndianStructure): + _fields_ = [ + ("frequency", c_ushort), + ("power", c_ushort), + ] + + def get_power(self): + """Retrieve AC power. + + Returns: + The power field, or None if it is unset. + """ + return None if self.power == UINT16_MAX else self.power + + def get_frequency(self): + """Retrieve AC frequency. + + Returns: + The frequency field in Hertz, or None if it is unset. + """ + return None if self.frequency == UINT16_MAX else self.frequency * 0.01 + + +class _TcpData(BigEndianStructure): + _pack_ = 1 + _fields_ = [ + # Unlikely for all these bytes to represent the model, + # this mapping has to be built out over time. + ("model", c_char * 3), + # The s/n from the request, twice, in little-endian + ("double_serial_number", (c_ubyte * 4) * 2), + ("padding0", c_char * 3), + ("serial_number", c_char * 16), + ("temperature", c_ushort), + ("dc_input_voltage", c_ushort * 3), + ("dc_input_current", c_ushort * 3), + ("ac_output_current", c_ushort * 3), + ("ac_output_voltage", c_ushort * 3), + ("ac_output", _AcOutput * 3), + ("solar_energy_today", c_ushort), + ("solar_energy_total", c_uint), + ("solar_hours_total", c_uint), + ("maybe_weekday_counter", c_ushort), + ("padding1", c_ubyte * 4), + ("unknown0", c_ushort), + ("padding2", c_ubyte * 10), + ("firmware", c_char * 16), + ("padding3", c_ubyte * 4), + ("firmware_slave", c_char * 16), + ("padding4", c_ubyte * 4), + ] + + +class TcpParser: + """Functions for interacting with the Omnik over TCP.""" + + MESSAGE_START = 0x68 + MESSAGE_END = 0x16 + + @classmethod + def _pack_message(cls, message: bytearray) -> bytearray: + checksum = sum(message) & 0xFF + + message.insert(0, cls.MESSAGE_START) + message.append(checksum) + message.append(cls.MESSAGE_END) + + return message + + @classmethod + def _unpack_message(cls, message: bytearray) -> bytearray: + if message.pop(0) != cls.MESSAGE_START: + raise OmnikInverterPacketInvalidError("Invalid start byte") + if message.pop() != cls.MESSAGE_END: + raise OmnikInverterPacketInvalidError("Invalid end byte") + + expected_checksum = message.pop() + checksum = sum(message) & 0xFF + if expected_checksum != checksum: + raise OmnikInverterPacketInvalidError( + f"Checksum mismatch (got `{checksum}` expected `{expected_checksum}`" + ) + + return message + + @classmethod + def create_information_request(cls, serial_number: int) -> bytearray: + """Compute a magic message to which the Omnik will reply with raw statistics. + + Args: + serial_number: Integer with the serial number of your Omnik device. + + Returns: + A bytearray with the raw message data, to be sent over a TCP socket. + """ + request_data = bytearray([0x02, 0x40, 0x30]) + serial_bytes = serial_number.to_bytes(length=4, byteorder="little") + request_data.extend(serial_bytes) + request_data.extend(serial_bytes) + request_data.extend([0x01, 0x00]) + return cls._pack_message(request_data) + + @classmethod + def parse_information_reply(cls, serial_number: int, data: bytes) -> dict[str, Any]: + """Perform a raw TCP request to the Omnik device. + + Args: + serial_number: Serial number passed to + `clk.create_information_request()`, used to validate the reply. + data: Raw data reply from the Omnik Inverter. + + Returns: + A Python dictionary (text) with the response from + the Omnik Inverter. + + Raises: + OmnikInverterPacketInvalidError: Received data fails basic validity checks. + """ + + data = cls._unpack_message(bytearray(data)) + data = _TcpData.from_buffer_copy(data) + + if any( + serial_number != int.from_bytes(b, byteorder="little") + for b in data.double_serial_number + ): + raise OmnikInverterPacketInvalidError("Serial number mismatch in reply") + + if data.unknown0 != UINT16_MAX: + print(f"Unexpected unknown0 `{data.unknown0}`") + + if data.padding0 != b"\x81\x02\x01": + print(f"Unexpected padding0 `{data.padding0}`") + + # For all data that's expected to be zero, print it if it's not. Perhaps + # there are more interesting fields on different inverters waiting to be + # uncovered. + for idx in range(1, 5): + name = f"padding{idx}" + padding = getattr(data, name) + if sum(padding): + print(f"Unexpected `{name}`: `{padding}`") + + def extract_model(magic: c_char * 3): + return { + b"\x7d\x41\xb0": "Omniksol 3000TL", + # http://www.mb200d.nl/wordpress/2015/11/omniksol-4k-tl-wifi-kit/#more-590: + b"\x81\x41\xb0": "Omniksol 4000TL", + }.get(magic, f"Unknown device model from magic {magic!r}") + + def list_divide_10(integers: list[c_ushort]) -> list[c_ushort]: + return [None if v == UINT16_MAX else v * 0.1 for v in integers] + + # Only these fields will be extracted from the structure + field_extractors = { + "model": extract_model, + "serial_number": None, + "temperature": 0.1, + "dc_input_voltage": list_divide_10, + "dc_input_current": list_divide_10, + "ac_output_current": list_divide_10, + "ac_output_voltage": list_divide_10, + "ac_output": None, + "solar_energy_today": 0.01, + "solar_energy_total": 0.1, + "solar_hours_total": None, + "firmware": None, + "firmware_slave": None, + } + + result = {} + + for (name, extractor) in field_extractors.items(): + value = getattr(data, name) + if name == "ac_output": + # Flatten the list of frequency+power AC objects + + result["ac_output_frequency"] = [o.get_frequency() for o in value] + result["ac_output_power"] = [o.get_power() for o in value] + continue + + if isinstance(extractor, float): + value *= extractor + elif extractor is not None: + value = extractor(value) + + result[name] = value + + return result diff --git a/test_output.py b/test_output.py index b9cc56dd..385648f9 100644 --- a/test_output.py +++ b/test_output.py @@ -4,6 +4,7 @@ import asyncio from omnikinverter import OmnikInverter +from omnikinverter.models import Device, Inverter async def main(): @@ -12,8 +13,8 @@ async def main(): host="examples.com", source_type="javascript", ) as client: - inverter: OmnikInverter = await client.inverter() - device: OmnikInverter = await client.device() + inverter: Inverter = await client.inverter() + device: Device = await client.device() print(inverter) print() print("-- INVERTER --") @@ -21,17 +22,42 @@ async def main(): print(f"Model: {inverter.model}") print(f"Firmware Main: {inverter.firmware}") print(f"Firmware Slave: {inverter.firmware_slave}") - print(f"Rated Power: {inverter.solar_rated_power}") - print(f"Current Power: {inverter.solar_current_power}") - print(f"Energy Production Today: {inverter.solar_energy_today}") - print(f"Energy Production Total: {inverter.solar_energy_total}") - print() - print(device) - print() - print("-- DEVICE --") - print(f"Signal Quality: {device.signal_quality}") - print(f"Firmware: {device.firmware}") - print(f"IP Address: {device.ip_address}") + print(f"Current Power: {inverter.solar_current_power}W") + print(f"Energy Production Today: {inverter.solar_energy_today}kWh") + print(f"Energy Production Total: {inverter.solar_energy_total}kWh") + + optional_fields = [ + ("solar_rated_power", "Rated Power", "W"), + ("temperature", "Temperature", "°C"), + ("solar_hours_total", "Solar Hours Total", "h"), + ("dc_input_voltage", "DC Input Voltage", "V"), + ("dc_input_current", "DC Input Current", "A"), + ("ac_output_voltage", "AC Output Voltage", "V"), + ("ac_output_current", "AC Output Current", "A"), + ("ac_output_frequency", "AC Output Frequency", "Hz"), + ("ac_output_power", "AC Output Power", "W"), + ] + + for field, name, unit in optional_fields: + if (val := getattr(inverter, field)) is not None: + if isinstance(val, list): + values = ", ".join( + f"{v2:.1f}{unit}" for v2 in val if v2 is not None + ) + print(f"{name}: {values}") + elif isinstance(val, float): + print(f"{name}: {val:.1f}{unit}") + else: + print(f"{name}: {val}{unit}") + + if device is not None: + print() + print(device) + print() + print("-- DEVICE --") + print(f"Signal Quality: {device.signal_quality}") + print(f"Firmware: {device.firmware}") + print(f"IP Address: {device.ip_address}") if __name__ == "__main__":