diff --git a/README.md b/README.md index 5f73e5c..1e4053b 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,17 @@ Usage: ./cli.py publish-loop [OPTIONS] IP AT-command. ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│ --port INTEGER RANGE [1000<=x<=65535] Port of the inverter │ -│ [default: 48899; 1000<=x<=65535] │ -│ --log/--no-log [default: log] │ -│ --verbose/--no-verbose [default: verbose] │ -│ --debug/--no-debug [default: no-debug] │ -│ --help Show this message and exit. │ +│ * --port INTEGER RANGE [1000<=x<=65535] Port of the inverter │ +│ [default: 48899; 1000<=x<=65535] │ +│ [required] │ +│ * --inverter TEXT Prefix of yaml config files in │ +│ inverter/definitions/ │ +│ [default: deye_2mppt] │ +│ [required] │ +│ --log/--no-log [default: log] │ +│ --verbose/--no-verbose [default: verbose] │ +│ --debug/--no-debug [default: no-debug] │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` [comment]: <> (✂✂✂ auto generated publish-loop help end ✂✂✂) @@ -90,10 +95,15 @@ Usage: ./cli.py print-values [OPTIONS] IP .../inverter-connect$ ./cli.py print-values 192.168.123.456 ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│ --port INTEGER RANGE [1000<=x<=65535] Port of the inverter │ -│ [default: 48899; 1000<=x<=65535] │ -│ --debug/--no-debug [default: no-debug] │ -│ --help Show this message and exit. │ +│ --port INTEGER RANGE [1000<=x<=65535] Port of the inverter │ +│ [default: 48899; 1000<=x<=65535] │ +│ * --inverter TEXT Prefix of yaml config files in │ +│ inverter/definitions/ │ +│ [default: deye_2mppt] │ +│ [required] │ +│ --verbose/--no-verbose [default: verbose] │ +│ --debug/--no-debug [default: no-debug] │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` [comment]: <> (✂✂✂ auto generated print-values help end ✂✂✂) @@ -127,10 +137,11 @@ Usage: ./cli.py print-at-commands [OPTIONS] IP [COMMANDS]... (Note: The prefix "AT+" will be added to every command) ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│ --port INTEGER RANGE [1000<=x<=65535] Port of the inverter │ -│ [default: 48899; 1000<=x<=65535] │ -│ --debug/--no-debug [default: no-debug] │ -│ --help Show this message and exit. │ +│ --port INTEGER RANGE [1000<=x<=65535] Port of the inverter │ +│ [default: 48899; 1000<=x<=65535] │ +│ --verbose/--no-verbose [default: verbose] │ +│ --debug/--no-debug [default: no-debug] │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` [comment]: <> (✂✂✂ auto generated print-at-commands help end ✂✂✂) @@ -158,10 +169,11 @@ Usage: ./cli.py read-register [OPTIONS] IP REGISTER LENGTH The start address can be pass as decimal number or as hex string, e.g.: 0x123 ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│ --port INTEGER RANGE [1000<=x<=65535] Port of the inverter │ -│ [default: 48899; 1000<=x<=65535] │ -│ --debug/--no-debug [default: debug] │ -│ --help Show this message and exit. │ +│ --port INTEGER RANGE [1000<=x<=65535] Port of the inverter │ +│ [default: 48899; 1000<=x<=65535] │ +│ --verbose/--no-verbose [default: verbose] │ +│ --debug/--no-debug [default: no-debug] │ +│ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` [comment]: <> (✂✂✂ auto generated read-register help end ✂✂✂) diff --git a/inverter/__init__.py b/inverter/__init__.py index 637bad8..2a8eb3a 100644 --- a/inverter/__init__.py +++ b/inverter/__init__.py @@ -3,5 +3,5 @@ Get information from Deye Microinverter """ -__version__ = '0.5.0' +__version__ = '0.6.0' __author__ = 'Jens Diemer ' diff --git a/inverter/api.py b/inverter/api.py index 34682b9..2b90401 100644 --- a/inverter/api.py +++ b/inverter/api.py @@ -1,37 +1,22 @@ from __future__ import annotations -import dataclasses import logging from collections.abc import Iterable from datetime import datetime -from enum import Enum from rich import print # noqa +from rich.pretty import pprint -from inverter.config import Config -from inverter.connection import InverterSock, ModbusReadResult +from inverter.connection import InverterSock +from inverter.data_types import Config, InverterValue, ModbusReadResult, ValueType from inverter.definitions import get_parameter +from inverter.exceptions import ValidationError +from inverter.validators import InverterValueValidator logger = logging.getLogger(__name__) -class ValueType(Enum): - READ_OUT = 'read out' - COMPUTED = 'computed' - - -@dataclasses.dataclass -class InverterValue: - type: ValueType - name: str - value: float | str - device_class: str # e.g.: "voltage" / "current" / "energy" etc. - state_class: str | None # e.g.: "measurement" / "total" / "total_increasing" etc. - unit: str # e.g.: "V" / "A" / "kWh" etc. - result: ModbusReadResult | None - - def compute_values(values: dict) -> Iterable[InverterValue]: total_power = None for no in range(1, 10): @@ -87,7 +72,8 @@ def compute_values(values: dict) -> Iterable[InverterValue]: class Inverter: def __init__(self, config: Config): self.config = config - self.parameters = get_parameter(yaml_filename=config.yaml_filename, debug=config.debug) + self.parameters = get_parameter(config=config) + self.value_validator = InverterValueValidator(config=config) self.inv_sock = InverterSock(config) def __enter__(self): @@ -119,6 +105,15 @@ def __iter__(self) -> Iterable[InverterValue]: unit=parameter.unit, result=result, ) + if self.config.debug: + pprint(value, indent_guides=False) + + try: + self.value_validator(inverter_value=value) + except ValidationError as err: + logger.info(f'Validation error: {err}') + raise + assert name not in values, f'Double {name=}: {value=} - {values=}' values[name] = value yield value diff --git a/inverter/cli/cli_app.py b/inverter/cli/cli_app.py index c71d28b..d50f961 100644 --- a/inverter/cli/cli_app.py +++ b/inverter/cli/cli_app.py @@ -15,11 +15,10 @@ import inverter from inverter import constants -from inverter.api import Inverter, ValueType, set_current_time -from inverter.config import Config -from inverter.connection import InverterInfo, InverterSock +from inverter.api import Inverter, set_current_time +from inverter.connection import InverterSock from inverter.constants import ERROR_STR_NO_DATA -from inverter.definitions import Parameter +from inverter.data_types import Config, InverterInfo, Parameter, ValueType from inverter.mqtt4homeassistant.data_classes import MqttSettings from inverter.mqtt4homeassistant.mqtt import get_connected_client from inverter.publish_loop import publish_forever @@ -53,6 +52,14 @@ type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True, path_type=Path) ) +INVERTER_NAME = dict( + required=True, + type=str, + default='deye_2mppt', + help='Prefix of yaml config files in inverter/definitions/', + show_default=True, +) + class ClickGroup(RichGroup): # FIXME: How to set the "info_name" easier? def make_context(self, info_name, *args, **kwargs): @@ -83,8 +90,10 @@ def version(): @click.option( '--port', type=click.IntRange(1000, 65535), default=48899, help='Port of the inverter', show_default=True ) +@click.option('--inverter', **INVERTER_NAME) +@click.option('--verbose/--no-verbose', **OPTION_ARGS_DEFAULT_TRUE) @click.option('--debug/--no-debug', **OPTION_ARGS_DEFAULT_FALSE) -def print_values(ip, port, debug): +def print_values(ip, port, inverter, verbose, debug): """ Print all known register values from Inverter, e.g.: @@ -92,7 +101,7 @@ def print_values(ip, port, debug): """ basic_log_setup(debug=debug) - config = Config(yaml_filename='deye_2mppt.yaml', host=ip, port=port, debug=debug) + config = Config(inverter_name=inverter, host=ip, port=port, verbose=verbose, debug=debug) with Inverter(config=config) as inverter: inverter_info: InverterInfo = inverter.inv_sock.inverter_info print(inverter_info) @@ -130,8 +139,9 @@ def print_values(ip, port, debug): @click.option( '--port', type=click.IntRange(1000, 65535), default=48899, help='Port of the inverter', show_default=True ) +@click.option('--verbose/--no-verbose', **OPTION_ARGS_DEFAULT_TRUE) @click.option('--debug/--no-debug', **OPTION_ARGS_DEFAULT_FALSE) -def print_at_commands(ip, port, commands, debug): +def print_at_commands(ip, port, commands, verbose, debug): """ Print one or more AT command values from Inverter. @@ -201,7 +211,7 @@ def print_at_commands(ip, port, commands, debug): 'DEVSELCTL', # Set/Get Web Device List Info ) - config = Config(yaml_filename=None, host=ip, port=port, debug=debug) + config = Config(inverter_name=None, host=ip, port=port, verbose=verbose, debug=debug) if debug: print(config) @@ -225,8 +235,9 @@ def print_at_commands(ip, port, commands, debug): '--port', type=click.IntRange(1000, 65535), default=48899, help='Port of the inverter', show_default=True ) @click.option('--register', default="0x16", help='Start address', show_default=True) +@click.option('--verbose/--no-verbose', **OPTION_ARGS_DEFAULT_TRUE) @click.option('--debug/--no-debug', **OPTION_ARGS_DEFAULT_TRUE) -def set_time(ip, port, register, debug): +def set_time(ip, port, register, verbose, debug): """ Set current date time in the inverter device. @@ -237,7 +248,7 @@ def set_time(ip, port, register, debug): """ address = convert_address_option(raw_address=register, debug=debug) - config = Config(yaml_filename=None, host=ip, port=port, debug=debug) + config = Config(inverter_name=None, host=ip, port=port, verbose=verbose, debug=debug) if debug: print(config) @@ -266,10 +277,11 @@ def set_time(ip, port, register, debug): @click.option( '--port', type=click.IntRange(1000, 65535), default=48899, help='Port of the inverter', show_default=True ) -@click.option('--debug/--no-debug', **OPTION_ARGS_DEFAULT_TRUE) @click.argument('register') @click.argument('length', type=click.IntRange(1, 100)) -def read_register(ip, port, register, length, debug): +@click.option('--verbose/--no-verbose', **OPTION_ARGS_DEFAULT_TRUE) +@click.option('--debug/--no-debug', **OPTION_ARGS_DEFAULT_FALSE) +def read_register(ip, port, register, length, verbose, debug): """ Read register(s) from the inverter @@ -286,9 +298,7 @@ def read_register(ip, port, register, length, debug): print(f'Read {length} register(s) from {register=!r} ({ip}:{port})') address = convert_address_option(raw_address=register, debug=debug) - config = Config(yaml_filename=None, host=ip, port=port, debug=debug) - if debug: - print(config) + config = Config(inverter_name=None, host=ip, port=port, verbose=verbose, debug=debug) with InverterSock(config) as inv_sock: inverter_info: InverterInfo = inv_sock.inverter_info @@ -380,12 +390,18 @@ def test_mqtt_connection(debug): @click.command() @click.argument('ip') @click.option( - '--port', type=click.IntRange(1000, 65535), default=48899, help='Port of the inverter', show_default=True + '--port', + type=click.IntRange(1000, 65535), + default=48899, + help='Port of the inverter', + show_default=True, + required=True, ) +@click.option('--inverter', **INVERTER_NAME) @click.option('--log/--no-log', **OPTION_ARGS_DEFAULT_TRUE) @click.option('--verbose/--no-verbose', **OPTION_ARGS_DEFAULT_TRUE) @click.option('--debug/--no-debug', **OPTION_ARGS_DEFAULT_FALSE) -def publish_loop(ip, port, log, verbose, debug): +def publish_loop(ip, port, inverter, log, verbose, debug): """ Publish current data via MQTT for Home Assistant (endless loop) @@ -395,7 +411,7 @@ def publish_loop(ip, port, log, verbose, debug): if log: basic_log_setup(debug=debug) - config = Config(yaml_filename='deye_2mppt.yaml', host=ip, port=port, debug=debug) + config = Config(inverter_name=inverter, host=ip, port=port, verbose=verbose, debug=debug) mqtt_settings: MqttSettings = get_mqtt_settings() pprint(mqtt_settings.anonymized()) diff --git a/inverter/config.py b/inverter/config.py deleted file mode 100644 index 7c2a960..0000000 --- a/inverter/config.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -import dataclasses -from datetime import time - - -@dataclasses.dataclass -class Config: - yaml_filename: str | None - - host: str - port: int = 48899 - - pause: float = 0.1 - timeout: int = 5 - - init_cmd: bytes = b'WIFIKIT-214028-READ' - debug: bool = False - - daily_production_name: str = 'Daily Production' # Must be the same as in yaml config! - reset_needed_start: time = time(hour=1) - reset_needed_end: time = time(hour=3) diff --git a/inverter/connection.py b/inverter/connection.py index adbdac9..c9dc53b 100644 --- a/inverter/connection.py +++ b/inverter/connection.py @@ -1,48 +1,19 @@ from __future__ import annotations -import dataclasses import logging import socket import time from rich import print # noqa -from inverter.config import Config from inverter.constants import AT_READ_FUNC_NUMBER, AT_WRITE_FUNC_NUMBER, ERROR_STR_NO_DATA -from inverter.definitions import Parameter +from inverter.data_types import Config, InverterInfo, ModbusReadResult, ModbusResponse, Parameter, RawModBusResponse from inverter.exceptions import CrcError, ModbusNoData, ModbusNoHexData, ParseModbusValueError, ReadTimeout logger = logging.getLogger(__name__) -@dataclasses.dataclass -class InverterInfo: - ip: str - mac: str - serial: int - - -@dataclasses.dataclass -class RawModBusResponse: - prefix: str - data: str - - -@dataclasses.dataclass -class ModbusResponse: - slave_id: int - modbus_function: int - data_hex: str - - -@dataclasses.dataclass -class ModbusReadResult: - parameter: Parameter - parsed_value: float | str - response: ModbusResponse = None - - def make_modbus_result(*, response: ModbusResponse, parameter: Parameter) -> ModbusReadResult: parser_func = parameter.parser data_hex = response.data_hex diff --git a/inverter/constants.py b/inverter/constants.py index 53f2d2c..21778a9 100644 --- a/inverter/constants.py +++ b/inverter/constants.py @@ -12,3 +12,7 @@ ERROR_STR_NO_DATA = 'no data' AT_READ_FUNC_NUMBER = 0x03 AT_WRITE_FUNC_NUMBER = 0x10 +TYPE_MAP = { + 'float': float, + 'int': int, +} diff --git a/inverter/daily_reset.py b/inverter/daily_reset.py index 4a22fc2..5df46fb 100644 --- a/inverter/daily_reset.py +++ b/inverter/daily_reset.py @@ -1,23 +1,12 @@ -import dataclasses import logging from datetime import datetime -from inverter.api import Inverter, InverterValue, set_current_time -from inverter.config import Config - +from inverter.api import Inverter, set_current_time +from inverter.data_types import Config, InverterValue, ResetState logger = logging.getLogger(__name__) -@dataclasses.dataclass -class ResetState: - started: datetime - set_time_count: int = 0 - successful_count: int = 0 - last_success_dt: datetime = None - reset_needed: bool = False - - class DailyProductionReset: """ Deye SUN600 will not automatically reset the "Daily Production" counter. diff --git a/inverter/data_types.py b/inverter/data_types.py new file mode 100644 index 0000000..14a6527 --- /dev/null +++ b/inverter/data_types.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import dataclasses +from datetime import datetime, time +from enum import Enum +from pathlib import Path +from typing import Callable + +import msgspec +from rich import print + +from inverter.constants import BASE_PATH, TYPE_MAP + + +class ValueType(Enum): + READ_OUT = 'read out' + COMPUTED = 'computed' + + +@dataclasses.dataclass +class InverterValue: + type: ValueType + name: str + value: float | str + device_class: str # e.g.: "voltage" / "current" / "energy" etc. + state_class: str | None # e.g.: "measurement" / "total" / "total_increasing" etc. + unit: str # e.g.: "V" / "A" / "kWh" etc. + result: ModbusReadResult | None + + +@dataclasses.dataclass +class InverterInfo: + ip: str + mac: str + serial: int + + +@dataclasses.dataclass +class RawModBusResponse: + prefix: str + data: str + + +@dataclasses.dataclass +class ModbusResponse: + slave_id: int + modbus_function: int + data_hex: str + + +@dataclasses.dataclass +class ModbusReadResult: + parameter: Parameter + parsed_value: float | str + response: ModbusResponse = None + + +@dataclasses.dataclass +class Config: + inverter_name: str | None + + host: str | None = None + port: int = 48899 + + pause: float = 0.1 + timeout: int = 5 + + init_cmd: bytes = b'WIFIKIT-214028-READ' + verbose: bool = True + debug: bool = False + + daily_production_name: str = 'Daily Production' # Must be the same as in yaml config! + reset_needed_start: time = time(hour=1) + reset_needed_end: time = time(hour=3) + + # Will be set by post init: + definition_file_path: Path = None + validation_file_path: Path = None + + def __post_init__(self): + if self.inverter_name: + self.definition_file_path = BASE_PATH / 'definitions' / f'{self.inverter_name}.yaml' + if not self.definition_file_path.is_file(): + raise FileNotFoundError( + f'Wrong inverter name: {self.inverter_name!r}: File not found: {self.definition_file_path}' + ) + + self.validation_file_path = BASE_PATH / 'definitions' / f'{self.inverter_name}_validations.yaml' + if not self.validation_file_path.is_file(): + raise FileNotFoundError( + f'Wrong inverter name: {self.inverter_name!r}: File not found: {self.validation_file_path}' + ) + + if self.verbose: + print(self) + + +@dataclasses.dataclass +class ResetState: + started: datetime + set_time_count: int = 0 + successful_count: int = 0 + last_success_dt: datetime = None + reset_needed: bool = False + + +@dataclasses.dataclass +class Parameter: + start_register: int + length: int + group: str + name: str # e.g.: "PV1 Voltage" / "PV1 Current" / "Daily Production" etc. + device_class: str # e.g.: "voltage" / "current" / "energy" etc. + state_class: str | None # e.g.: "measurement" / "total" / "total_increasing" etc. + unit: str # e.g.: "V" / "A" / "kWh" etc. + scale: float # e.g.: 1 / 0.1 + parser: Callable + offset: int | None = None + lookup: dict | None = None + + +@dataclasses.dataclass +class ValueSpecs: + name: str + type: str + min_value: float + max_value: float + type_func: Callable = None + + def __post_init__(self): + try: + self.type_func = TYPE_MAP[self.type] + except KeyError: + raise KeyError(f'Unsupported type: {self.type!r}') + + if self.min_value is not None: + self.min_value = self.type_func(self.min_value) + + if self.max_value is not None: + self.max_value = self.type_func(self.max_value) + + +class Validators(msgspec.Struct): + validators: list[ValueSpecs] diff --git a/inverter/definitions.py b/inverter/definitions.py index 811466c..4e20b76 100644 --- a/inverter/definitions.py +++ b/inverter/definitions.py @@ -1,32 +1,15 @@ from __future__ import annotations -import dataclasses from collections.abc import Iterable -from typing import Callable import yaml from bx_py_utils.dict_utils import pluck from bx_py_utils.path import assert_is_file -from inverter.constants import BASE_PATH +from inverter.data_types import Config, Parameter from inverter.utilities.modbus_converter import debug_converter, parse_number, parse_string, parse_swapped_number -@dataclasses.dataclass -class Parameter: - start_register: int - length: int - group: str - name: str # e.g.: "PV1 Voltage" / "PV1 Current" / "Daily Production" etc. - device_class: str # e.g.: "voltage" / "current" / "energy" etc. - state_class: str | None # e.g.: "measurement" / "total" / "total_increasing" etc. - unit: str # e.g.: "V" / "A" / "kWh" etc. - scale: float | int # e.g.: 1 / 0.1 - parser: Callable - offset: int | None = None - lookup: dict | None = None - - rule2converter = { 1: parse_number, 3: parse_swapped_number, @@ -34,10 +17,10 @@ class Parameter: } -def get_definition(yaml_filename): - yaml_path = BASE_PATH / 'definitions' / yaml_filename - assert_is_file(yaml_path) - content = yaml_path.read_text(encoding='UTF-8') +def get_definition(*, config: Config): + definition_file_path = config.definition_file_path + assert_is_file(definition_file_path) + content = definition_file_path.read_text(encoding='UTF-8') data = yaml.safe_load(content) return data['parameters'] @@ -50,8 +33,8 @@ def convert_lookup(raw_lookup: list): return {entry['key']: entry['value'] for entry in raw_lookup} -def get_parameter(yaml_filename, debug=False) -> Iterable[Parameter]: - data = get_definition(yaml_filename) +def get_parameter(*, config: Config) -> Iterable[Parameter]: + data = get_definition(config=config) parameters = [] for group_data in data: group_name = group_data['group'] diff --git a/inverter/definitions/deye_2mppt_validations.yaml b/inverter/definitions/deye_2mppt_validations.yaml new file mode 100644 index 0000000..47cf399 --- /dev/null +++ b/inverter/definitions/deye_2mppt_validations.yaml @@ -0,0 +1,10 @@ +validators: + - name: "Radiator Temperature" + type: "float" + min_value: -9.9 + max_value: 60 + + - name: "Total AC Output Power (Active)" + type: "int" + min_value: 0 + max_value: 600 diff --git a/inverter/exceptions.py b/inverter/exceptions.py index caa0355..38c67a9 100644 --- a/inverter/exceptions.py +++ b/inverter/exceptions.py @@ -25,3 +25,11 @@ class ParseModbusValueError(ReadInverterError): class ReadTimeout(ReadInverterError): pass + + +class ValidationError(AssertionError): + """ + A readed inverter value is not valid. + """ + + pass diff --git a/inverter/publish_loop.py b/inverter/publish_loop.py index ddcf8a8..3d4876f 100644 --- a/inverter/publish_loop.py +++ b/inverter/publish_loop.py @@ -4,12 +4,11 @@ from rich import print # noqa -from inverter.api import Inverter, InverterValue -from inverter.config import Config -from inverter.connection import InverterInfo +from inverter.api import Inverter from inverter.constants import ERROR_STR_NO_DATA -from inverter.daily_reset import DailyProductionReset, ResetState -from inverter.exceptions import ReadInverterError, ReadTimeout +from inverter.daily_reset import DailyProductionReset +from inverter.data_types import Config, InverterInfo, InverterValue, ResetState +from inverter.exceptions import ReadInverterError, ReadTimeout, ValidationError from inverter.mqtt4homeassistant.converter import values2mqtt_payload from inverter.mqtt4homeassistant.data_classes import HaValue, HaValues, MqttSettings from inverter.mqtt4homeassistant.mqtt import HaMqttPublisher @@ -50,6 +49,8 @@ def publish_forever(*, mqtt_settings: MqttSettings, config: Config, verbose): unit=value.unit, ) ) + except ValidationError as err: + print(f'[red]Skip send values: {err}') except ReadInverterError as err: print(f'[red]{err}') else: diff --git a/inverter/tests/test_api.py b/inverter/tests/test_api.py index ae4fd20..59f6896 100644 --- a/inverter/tests/test_api.py +++ b/inverter/tests/test_api.py @@ -1,6 +1,7 @@ from unittest import TestCase -from inverter.api import InverterValue, ValueType, compute_values +from inverter.api import compute_values +from inverter.data_types import InverterValue, ValueType class ApiTestCase(TestCase): diff --git a/inverter/tests/test_connect.py b/inverter/tests/test_connect.py index ca3b757..4dbdad7 100644 --- a/inverter/tests/test_connect.py +++ b/inverter/tests/test_connect.py @@ -1,6 +1,7 @@ from unittest import TestCase -from inverter.connection import ModbusResponse, Parameter, parse_modbus_response +from inverter.connection import parse_modbus_response +from inverter.data_types import ModbusResponse, Parameter def get_parameter(**kwargs) -> Parameter: diff --git a/inverter/tests/test_daily_reset.py b/inverter/tests/test_daily_reset.py index b73c7c9..0581ba3 100644 --- a/inverter/tests/test_daily_reset.py +++ b/inverter/tests/test_daily_reset.py @@ -4,9 +4,8 @@ from freezegun import freeze_time -from inverter.api import InverterValue, ValueType -from inverter.config import Config -from inverter.daily_reset import DailyProductionReset, ResetState +from inverter.daily_reset import DailyProductionReset +from inverter.data_types import Config, InverterValue, ResetState, ValueType class DailyProductionResetTestCase(TestCase): @@ -27,7 +26,7 @@ def write(self, *, address, values): self.writes.append((address, values)) inverter = InverterMock() - config = Config(yaml_filename=None, host='foo') + config = Config(inverter_name=None, verbose=False) with DailyProductionReset(reset_state, inverter, config) as daily_production_reset: self.assertEqual( diff --git a/inverter/tests/test_definitions.py b/inverter/tests/test_definitions.py index 9df430d..40dd483 100644 --- a/inverter/tests/test_definitions.py +++ b/inverter/tests/test_definitions.py @@ -1,12 +1,14 @@ from unittest import TestCase -from inverter.definitions import Parameter, get_definition, get_parameter +from inverter.data_types import Config, Parameter +from inverter.definitions import get_definition, get_parameter from inverter.utilities.modbus_converter import parse_number class DefinitionsTestCase(TestCase): def test_get_definition(self): - result = get_definition(yaml_filename='deye_2mppt.yaml') + config = Config(inverter_name='deye_2mppt', verbose=False) + result = get_definition(config=config) self.assertIsInstance(result, list) example = result[0]['items'][0] self.assertEqual( @@ -24,7 +26,8 @@ def test_get_definition(self): ) def test_get_parameter(self): - parameters = get_parameter(yaml_filename='deye_2mppt.yaml') + config = Config(inverter_name='deye_2mppt', verbose=False) + parameters = get_parameter(config=config) self.assertIsInstance(parameters, list) example = parameters[0] self.assertEqual( diff --git a/inverter/tests/test_validators.py b/inverter/tests/test_validators.py new file mode 100644 index 0000000..98f8b93 --- /dev/null +++ b/inverter/tests/test_validators.py @@ -0,0 +1,57 @@ +import logging +from unittest import TestCase + +from inverter.data_types import Config, InverterValue, ValueType +from inverter.exceptions import ValidationError +from inverter.validators import InverterValueValidator + + +class ValidatorsTestCase(TestCase): + def test_happy_path(self): + config = Config(inverter_name='deye_2mppt', verbose=False) + validator = InverterValueValidator(config=config) + + with self.assertLogs(logger=None, level=logging.DEBUG) as logs: + validator( + inverter_value=InverterValue( + type=ValueType.COMPUTED, + name='Total Power', + value=30, + device_class='power', + state_class='measurement', + unit='W', + result=None, + ) + ) + self.assertEqual( + logs.output, + ["DEBUG:inverter.validators:No validation specs for: 'Total Power', ok."], + ) + + with self.assertLogs(logger=None, level=logging.DEBUG) as logs: + validator( + inverter_value=InverterValue( + type=ValueType.READ_OUT, + name='Radiator Temperature', + value=30.5, + device_class='temperature', + state_class='measurement', + unit='°C', + result=None, + ) + ) + self.assertEqual(logs.output, ['DEBUG:inverter.validators:Radiator Temperature value=30.5 is valid, ok.']) + + with self.assertRaises(ValidationError) as err: + validator( + inverter_value=InverterValue( + type=ValueType.READ_OUT, + name='Radiator Temperature', + value=-10, + device_class='temperature', + state_class='measurement', + unit='°C', + result=None, + ) + ) + self.assertEqual(str(err.exception), 'Radiator Temperature value=-10.0 is less than -9.9') diff --git a/inverter/utilities/cli.py b/inverter/utilities/cli.py index 88a8e24..1bb92b9 100644 --- a/inverter/utilities/cli.py +++ b/inverter/utilities/cli.py @@ -3,7 +3,7 @@ from rich.console import Console from rich.table import Table -from inverter.connection import ModbusResponse +from inverter.data_types import ModbusResponse from inverter.exceptions import ModbusNoData, ModbusNoHexData diff --git a/inverter/validators.py b/inverter/validators.py new file mode 100644 index 0000000..431491d --- /dev/null +++ b/inverter/validators.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import logging + +import msgspec +from bx_py_utils.path import assert_is_file +from rich import print # noqa + +from inverter.data_types import Config, InverterValue, Validators, ValueSpecs +from inverter.exceptions import ValidationError + + +logger = logging.getLogger(__name__) + + +def get_validator_specs(*, config: Config) -> list[ValueSpecs]: + validation_file_path = config.validation_file_path + assert_is_file(validation_file_path) + data = validation_file_path.read_text(encoding='UTF-8') + + validators = msgspec.yaml.decode(data, type=Validators) + return validators.validators + + +class InverterValueValidator: + def __init__(self, *, config: Config): + self.config = config + specs = get_validator_specs(config=config) + self.spec_map = {spec.name: spec for spec in specs} + + def __call__(self, *, inverter_value: InverterValue): + try: + spec: ValueSpecs = self.spec_map[inverter_value.name] + except KeyError as err: + logger.debug(f'No validation specs for: {err}, ok.') + return + + value = inverter_value.value + + value = spec.type_func(value) + + if spec.min_value and value < spec.min_value: + raise ValidationError(f'{inverter_value.name} {value=!r} is less than {spec.min_value!r}') + + if spec.max_value and value > spec.max_value: + raise ValidationError(f'{inverter_value.name} {value=!r} is greater than {spec.min_value!r}') + + logger.debug(f'{inverter_value.name} {value=!r} is valid, ok.') diff --git a/pyproject.toml b/pyproject.toml index 1a25bf0..d5f2364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ authors = [ requires-python = ">=3.9,<4" dependencies = [ "paho-mqtt", # https://pypi.org/project/paho-mqtt/ + "msgspec", # https://github.com/jcrist/msgspec "pyyaml", # https://github.com/yaml/pyyaml "bx_py_utils", # https://github.com/boxine/bx_py_utils "click", # https://github.com/pallets/click/ diff --git a/requirements.dev.txt b/requirements.dev.txt index 25787d0..e383b37 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -463,6 +463,37 @@ more-itertools==9.1.0 \ --hash=sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d \ --hash=sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3 # via jaraco-classes +msgspec==0.14.2 \ + --hash=sha256:03ddc8c518afbea4fb89afb587d77f11d00909f003966d437f31fb8fffdfac28 \ + --hash=sha256:1c8f7e631fad9d5a33fcfb0f2a27eb86dc0390e91d6b51c95a604b7ccbc264f1 \ + --hash=sha256:1fc99f0929fa91cc53fa35f59be366d67f116c2b7f0f3b29dc60e3179f6fb205 \ + --hash=sha256:2039451b22813af2fd5cbe99eaecfc318d64fcee5af0ce5b3d5cce12427d24cd \ + --hash=sha256:26bcb3a69b348be2757ab19e86038e586920522a99d49d358c12890fbcfb6aa8 \ + --hash=sha256:3288b65ee7c78d08f32003a8b5ca72fff12c6a7400bd35f9d65630c9d58efce2 \ + --hash=sha256:4f13e47803aedbb32c9375317fedbd20af3397dc024d311eebdc635c07f6f908 \ + --hash=sha256:57a79cfa306fda2c66f4fc7eb72836c0f78fd9a6d748d028960b387797f0381b \ + --hash=sha256:580464c7ca5c47a1422973c853301bbfd3d1a4184bdb6bddb73b5df094d8fc55 \ + --hash=sha256:587371a65798a0f0182d0a7a4b7c4b87a5f46e25e8821c6474b3f717dcfcad14 \ + --hash=sha256:59491de3566c7789bdb0a152f305e150a6ba3e825af471680b05a029a664a89a \ + --hash=sha256:78361dadef4b993b8c4a887d3d267b89b0ea0846259eadf2fe993659e4dbf9c8 \ + --hash=sha256:7e50885274e2041e49ec5d7cce8e59768f599c27dfb4c046edaf9ab25b1fddc2 \ + --hash=sha256:87c4cd1bb197be11f89ad779038c8989d6ffcb8b360705107f034e4d2783c0a6 \ + --hash=sha256:8927efaf506e5a8f9ffe76602e08d30a80c19b38d50a7e783887c317573ecd80 \ + --hash=sha256:8b8a766b9f3e7f87946965a8ffc6e72f7a3ec8d031b3168df16762bfd3d03205 \ + --hash=sha256:8ed61cad6b20f0218a8d239294c4b30b4e82854871ba0434cf0d54497043bffe \ + --hash=sha256:907ed4305a97b50248e6b86e79ddc8edcf9b718eab0c93a6b46d673c5edbe3a4 \ + --hash=sha256:94fc3d9a8835f18c18b48fdf49f7d445184061bfbc457a6623a4eb1f74ebe806 \ + --hash=sha256:a0a3908309581e4e632457fac1938fec7fd84121396ddab6ddca37784e6db068 \ + --hash=sha256:a12e704786256431d559c2027d6135a64f2339f009118d97906709cd8409e7ac \ + --hash=sha256:b965c14851f146537f1b732cd2ed16c38e0c59662f23b72d396aee21e81aed4f \ + --hash=sha256:d03861f0d271b696faefb1a885ea0c7dc7db70baaa05c7f18086f2b9085d1cb8 \ + --hash=sha256:d469aede5d986223d6ec9a8d0713156f96fd6b427b12e14f81d26627a47687b9 \ + --hash=sha256:d85e9bfd1441216010c084626d968e96a3d88d762959c5eb430de62076cd7fe9 \ + --hash=sha256:decd1d2015d340ebfd58f29ed2916e118ca255b6a94fc1787a236a2654dfd8ff \ + --hash=sha256:eee59e73982ca0d730f8d4e8fb5f01da9fa466490dea43ea1bcfa23b8a8bbc0d \ + --hash=sha256:f97006b9c9e24e9677fb84f43586fb4d03a72eb426199656a1c24775c62b9fe4 \ + --hash=sha256:ff7c987330e2be62eb8811bc2da33507e8edeb761f4fd343f2fa5fdafce4f989 + # via inverter-connect (pyproject.toml) mypy==1.2.0 \ --hash=sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521 \ --hash=sha256:031fc69c9a7e12bcc5660b74122ed84b3f1c505e762cc4296884096c6d8ee140 \ diff --git a/requirements.txt b/requirements.txt index 21c9017..07fe5e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,37 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py +msgspec==0.14.2 \ + --hash=sha256:03ddc8c518afbea4fb89afb587d77f11d00909f003966d437f31fb8fffdfac28 \ + --hash=sha256:1c8f7e631fad9d5a33fcfb0f2a27eb86dc0390e91d6b51c95a604b7ccbc264f1 \ + --hash=sha256:1fc99f0929fa91cc53fa35f59be366d67f116c2b7f0f3b29dc60e3179f6fb205 \ + --hash=sha256:2039451b22813af2fd5cbe99eaecfc318d64fcee5af0ce5b3d5cce12427d24cd \ + --hash=sha256:26bcb3a69b348be2757ab19e86038e586920522a99d49d358c12890fbcfb6aa8 \ + --hash=sha256:3288b65ee7c78d08f32003a8b5ca72fff12c6a7400bd35f9d65630c9d58efce2 \ + --hash=sha256:4f13e47803aedbb32c9375317fedbd20af3397dc024d311eebdc635c07f6f908 \ + --hash=sha256:57a79cfa306fda2c66f4fc7eb72836c0f78fd9a6d748d028960b387797f0381b \ + --hash=sha256:580464c7ca5c47a1422973c853301bbfd3d1a4184bdb6bddb73b5df094d8fc55 \ + --hash=sha256:587371a65798a0f0182d0a7a4b7c4b87a5f46e25e8821c6474b3f717dcfcad14 \ + --hash=sha256:59491de3566c7789bdb0a152f305e150a6ba3e825af471680b05a029a664a89a \ + --hash=sha256:78361dadef4b993b8c4a887d3d267b89b0ea0846259eadf2fe993659e4dbf9c8 \ + --hash=sha256:7e50885274e2041e49ec5d7cce8e59768f599c27dfb4c046edaf9ab25b1fddc2 \ + --hash=sha256:87c4cd1bb197be11f89ad779038c8989d6ffcb8b360705107f034e4d2783c0a6 \ + --hash=sha256:8927efaf506e5a8f9ffe76602e08d30a80c19b38d50a7e783887c317573ecd80 \ + --hash=sha256:8b8a766b9f3e7f87946965a8ffc6e72f7a3ec8d031b3168df16762bfd3d03205 \ + --hash=sha256:8ed61cad6b20f0218a8d239294c4b30b4e82854871ba0434cf0d54497043bffe \ + --hash=sha256:907ed4305a97b50248e6b86e79ddc8edcf9b718eab0c93a6b46d673c5edbe3a4 \ + --hash=sha256:94fc3d9a8835f18c18b48fdf49f7d445184061bfbc457a6623a4eb1f74ebe806 \ + --hash=sha256:a0a3908309581e4e632457fac1938fec7fd84121396ddab6ddca37784e6db068 \ + --hash=sha256:a12e704786256431d559c2027d6135a64f2339f009118d97906709cd8409e7ac \ + --hash=sha256:b965c14851f146537f1b732cd2ed16c38e0c59662f23b72d396aee21e81aed4f \ + --hash=sha256:d03861f0d271b696faefb1a885ea0c7dc7db70baaa05c7f18086f2b9085d1cb8 \ + --hash=sha256:d469aede5d986223d6ec9a8d0713156f96fd6b427b12e14f81d26627a47687b9 \ + --hash=sha256:d85e9bfd1441216010c084626d968e96a3d88d762959c5eb430de62076cd7fe9 \ + --hash=sha256:decd1d2015d340ebfd58f29ed2916e118ca255b6a94fc1787a236a2654dfd8ff \ + --hash=sha256:eee59e73982ca0d730f8d4e8fb5f01da9fa466490dea43ea1bcfa23b8a8bbc0d \ + --hash=sha256:f97006b9c9e24e9677fb84f43586fb4d03a72eb426199656a1c24775c62b9fe4 \ + --hash=sha256:ff7c987330e2be62eb8811bc2da33507e8edeb761f4fd343f2fa5fdafce4f989 + # via inverter-connect (pyproject.toml) paho-mqtt==1.6.1 \ --hash=sha256:2a8291c81623aec00372b5a85558a372c747cbca8e9934dfe218638b8eefc26f # via inverter-connect (pyproject.toml)