diff --git a/docs/configuration.rst b/docs/configuration.rst index 2ea76a0..d6a0363 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -166,7 +166,7 @@ Example: .. autopydantic_model:: sml2mqtt.config.inputs.HttpSourceSettings - :exclude-members: get_device_name + :exclude-members: get_device_name, get_request_timeout Example: diff --git a/requirements.txt b/requirements.txt index 0b919aa..40df5ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,4 @@ pytest-asyncio == 0.23.6 aioresponses == 0.7.6 # Linter -ruff == 0.3.4 +ruff == 0.3.7 diff --git a/requirements_setup.txt b/requirements_setup.txt index 89402d8..443763a 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,6 +1,6 @@ aiomqtt == 2.0.1 pyserial-asyncio == 0.6 easyconfig == 0.3.2 -pydantic == 2.6.4 +pydantic == 2.7.0 smllib == 1.4 -aiohttp == 3.9.3 +aiohttp == 3.9.4 diff --git a/src/sml2mqtt/__log__.py b/src/sml2mqtt/__log__.py index 5ef14dc..6c11b47 100644 --- a/src/sml2mqtt/__log__.py +++ b/src/sml2mqtt/__log__.py @@ -35,7 +35,8 @@ def setup_log(): # This is the longest logger name str chars = 0 for device in sml2mqtt.CONFIG.inputs: - chars = max(len(f'sml.device.{device.url}'), chars) + # Name of the longest logger, should be the device status + chars = max(len(get_logger(device.get_device_name()).getChild('status').name), chars) log_format = logging.Formatter("[{asctime:s}] [{name:" + str(chars) + "s}] {levelname:8s} | {message:s}", style='{') # File Handler diff --git a/src/sml2mqtt/__version__.py b/src/sml2mqtt/__version__.py index 6b1404e..70dcefa 100644 --- a/src/sml2mqtt/__version__.py +++ b/src/sml2mqtt/__version__.py @@ -1 +1 @@ -__version__ = '3.0.DEV-7' +__version__ = '3.0.DEV-8' diff --git a/src/sml2mqtt/config/config.py b/src/sml2mqtt/config/config.py index b1dff39..44f508e 100644 --- a/src/sml2mqtt/config/config.py +++ b/src/sml2mqtt/config/config.py @@ -44,8 +44,8 @@ class Settings(AppBaseModel): def default_config() -> Settings: # File defaults return Settings( - inputs=[SerialSourceSettings(type='serial', url='COM1', timeout=3), - SerialSourceSettings(type='serial', url='/dev/ttyS0', timeout=3), ], + inputs=[SerialSourceSettings(type='serial', url='COM1', timeout=6), + SerialSourceSettings(type='serial', url='/dev/ttyS0', timeout=6), ], devices={ 'device_id_hex': SmlDeviceConfig( mqtt=OptionalMqttPublishConfig(topic='DEVICE_BASE_TOPIC'), diff --git a/src/sml2mqtt/config/inputs.py b/src/sml2mqtt/config/inputs.py index 97b0944..9d5795b 100644 --- a/src/sml2mqtt/config/inputs.py +++ b/src/sml2mqtt/config/inputs.py @@ -1,19 +1,21 @@ from typing import Literal import serial +from aiohttp import ClientTimeout from easyconfig import BaseModel from pydantic import ( AnyHttpUrl, Field, StrictFloat, StrictInt, - confloat, constr, field_validator, model_validator, ) from typing_extensions import override +from sml2mqtt.config.types import log + class SmlSourceSettingsBase(BaseModel): def get_device_name(self) -> str: @@ -25,7 +27,7 @@ class SerialSourceSettings(SmlSourceSettingsBase): url: constr(strip_whitespace=True, min_length=1, strict=True) = Field(..., description='Device path') timeout: StrictInt | StrictFloat = Field( - default=3, description='Seconds after which a timeout will be detected (default=3)') + default=6, description='Seconds after which a timeout will be detected (default=6)') baudrate: int = Field(9600, in_file=False) parity: str = Field('None', in_file=False) @@ -76,9 +78,9 @@ class HttpSourceSettings(SmlSourceSettingsBase): url: AnyHttpUrl = Field(..., description='Url') timeout: StrictInt | StrictFloat = Field( - default=3, description='Seconds after which a timeout will be detected (default=3)') + default=6, description='Seconds after which a timeout will be detected (default=6)') - interval: StrictInt | StrictFloat = Field(default=1, description='Delay between requests', ge=0.1) + interval: StrictInt | StrictFloat = Field(default=2, description='Delay between requests', ge=0.1) user: str = Field(default='', description='User (if needed)') password: str = Field(default='', description='Password (if needed)') @@ -88,8 +90,16 @@ def get_device_name(self) -> str: @model_validator(mode='after') def check_timeout_gt_interval(self): - if self.timeout <= self.interval: - msg = 'Timeout must be greater than interval' + if self.interval * 2 > self.timeout: + msg = 'Timeout must be greater equal than 2 * interval' raise ValueError(msg) + # Timeout is interval, and we automatically retry 3 times before we fail + if self.interval * 3 > self.timeout: + log.warning('The recommendation for timeout should is least 3 * interval ' + f'({self.interval * 3:.0f})! Is {self.timeout}') + return self + + def get_request_timeout(self) -> ClientTimeout: + return ClientTimeout(self.interval) diff --git a/src/sml2mqtt/config/types.py b/src/sml2mqtt/config/types.py index bbd04f5..b6e6459 100644 --- a/src/sml2mqtt/config/types.py +++ b/src/sml2mqtt/config/types.py @@ -2,6 +2,11 @@ from pydantic import Strict, StrictFloat, StrictInt, StringConstraints +from sml2mqtt.__log__ import get_logger + + +log = get_logger('config') + ObisHex = Annotated[ str, diff --git a/src/sml2mqtt/sml_source/http.py b/src/sml2mqtt/sml_source/http.py index 64d9dd3..c7594d6 100644 --- a/src/sml2mqtt/sml_source/http.py +++ b/src/sml2mqtt/sml_source/http.py @@ -1,6 +1,6 @@ from __future__ import annotations -from asyncio import sleep +from asyncio import TimeoutError, sleep from typing import TYPE_CHECKING, Final from aiohttp import BasicAuth, ClientError, ClientSession, ClientTimeout @@ -53,7 +53,7 @@ async def create(cls, device: DeviceProto, settings: HttpSourceSettings): if settings.user or settings.password: auth = BasicAuth(settings.user, settings.password) - return cls(device, str(settings.url), settings.interval, auth, timeout=ClientTimeout(settings.timeout / 2)) + return cls(device, str(settings.url), settings.interval, auth, timeout=settings.get_request_timeout()) def __init__(self, device: DeviceProto, url: str, interval: float, @@ -98,11 +98,11 @@ async def _http_task(self) -> None: payload = await resp.read() com_errors = 0 except Exception as e: - if isinstance(e, (ClientError, HttpStatusError)): + if isinstance(e, (ClientError, HttpStatusError, TimeoutError)): com_errors += 1 - max_ignore: int = 3 + max_ignore: int = 7 if com_errors <= max_ignore: - interval = com_errors * self.interval / max_ignore + interval = (((com_errors - 0.5) ** 2) / 4 + 0.5) * self.interval log.debug(f'Ignored {com_errors:d}/{max_ignore:d} {e}') continue diff --git a/tests/config/test_default.py b/tests/config/test_default.py index b6192dd..f41f933 100644 --- a/tests/config/test_default.py +++ b/tests/config/test_default.py @@ -32,10 +32,10 @@ def test_default(): inputs: - type: serial url: COM1 # Device path - timeout: 3 # Seconds after which a timeout will be detected (default=3) + timeout: 6 # Seconds after which a timeout will be detected (default=6) - type: serial url: /dev/ttyS0 # Device path - timeout: 3 # Seconds after which a timeout will be detected (default=3) + timeout: 6 # Seconds after which a timeout will be detected (default=6) devices: # Device configuration by ID or url device_id_hex: mqtt: # Optional MQTT configuration for this meter. diff --git a/tests/test_source/test_create.py b/tests/test_source/test_create.py index 84b8bf3..15775c9 100644 --- a/tests/test_source/test_create.py +++ b/tests/test_source/test_create.py @@ -6,7 +6,7 @@ async def test_create_http_no_auth(device_mock): - cfg = HttpSourceSettings(type='http', url='http://localhost/a', interval=3, timeout=6) + cfg = HttpSourceSettings(type='http', url='http://localhost/a', interval=3, timeout=9) obj = await create_source(device_mock, cfg) assert isinstance(obj, HttpSource) @@ -20,7 +20,7 @@ async def test_create_http_no_auth(device_mock): async def test_create_http_auth(device_mock): - cfg = HttpSourceSettings(type='http', url='http://localhost/a', interval=3, timeout=6, user='u', password='p') + cfg = HttpSourceSettings(type='http', url='http://localhost/a', interval=3, timeout=9, user='u', password='p') obj = await create_source(device_mock, cfg) assert isinstance(obj, HttpSource) diff --git a/tests/test_source/test_http.py b/tests/test_source/test_http.py index 1290e4a..eb9c338 100644 --- a/tests/test_source/test_http.py +++ b/tests/test_source/test_http.py @@ -1,4 +1,5 @@ import sys +from asyncio import TimeoutError import pytest from aiohttp import ClientTimeout @@ -34,13 +35,32 @@ async def test_200(sml_data_1, device_mock, source): @pytest.mark.skipif(sys.platform.lower() != "win32", reason="It's a mystery why this fails in CI") -async def test_400(device_mock, source): +async def test_400_then_200(sml_data_1, device_mock, source): with aioresponses() as m: m.get(source.url, status=404) m.get(source.url, status=404) - m.get(source.url, status=404) - m.get(source.url, status=404) + m.get(source.url, body=sml_data_1) + + source.start() + try: + await wait_for_call(device_mock.on_source_data, 1) + finally: + await source.cancel_and_wait() + + device_mock.on_source_data.assert_called_once_with(sml_data_1) + device_mock.on_source_failed.assert_not_called() + device_mock.on_error.assert_not_called() + + await close_session() + + +@pytest.mark.skipif(sys.platform.lower() != "win32", reason="It's a mystery why this fails in CI") +async def test_400(device_mock, source): + + with aioresponses() as m: + for _ in range(10): + m.get(source.url, status=404) source.start() try: @@ -57,3 +77,25 @@ async def test_400(device_mock, source): def test_error_repr(): assert str(HttpStatusError(404)) == 'HttpStatusError: 404' + + +@pytest.mark.skipif(sys.platform.lower() != "win32", reason="It's a mystery why this fails in CI") +async def test_timeout(device_mock, source): + + e = TimeoutError() + + with aioresponses() as m: + for _ in range(10): + m.get(source.url, exception=e) + + source.start() + try: + await wait_for_call(device_mock.on_error, 1) + finally: + await source.cancel_and_wait() + + device_mock.on_source_data.assert_not_called() + device_mock.on_source_failed.assert_not_called() + device_mock.on_error.assert_called_once_with(e, show_traceback=False) + + await close_session()