From c9a29def804a87c32cca4fb56276433087e65973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Sun, 19 Apr 2026 20:44:10 +0200 Subject: [PATCH 1/2] Convert to typed package with exception hierarchy Prep work for Home Assistant Platinum quality. The version bump and connection-state API land in follow-up PRs. - Convert to a proper package with a py.typed marker so PEP 561 strict-typing consumers (including Home Assistant's quality scale) see pynobo as a typed library. - Add a PynoboError hierarchy (PynoboConnectionError, PynoboHandshakeError, PynoboValidationError). PynoboValidationError still inherits ValueError so existing callers keep working. - Replace bare Exception and ValueError raises with the new hierarchy. - Add type hints across the public API. - Emit DeprecationWarning when synchronous=True; it will be removed in pynobo 2.0. - Add pyproject.toml (PEP 621) alongside setup.py. - Raise minimum Python to 3.10 and refresh the CI matrix to 3.10-3.14 (drops EOL 3.8/3.9, adds 3.13/3.14). - Expand tests to cover the new exception classes and the py.typed marker; document the deprecation and exceptions in README. --- .github/workflows/python-test.yml | 2 +- MANIFEST.in | 3 + README.md | 19 ++- pynobo.py => pynobo/__init__.py | 247 ++++++++++++++++++++---------- pynobo/py.typed | 0 pyproject.toml | 37 +++++ setup.py | 8 +- test_pynobo.py | 33 +++- 8 files changed, 263 insertions(+), 86 deletions(-) rename pynobo.py => pynobo/__init__.py (85%) create mode 100644 pynobo/py.typed create mode 100644 pyproject.toml diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 6b76423..0559f9a 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: false steps: - uses: actions/checkout@v4 diff --git a/MANIFEST.in b/MANIFEST.in index 676bed9..1fc3365 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,6 @@ include *.md # Include the license file include LICENSE + +# Include the py.typed marker for PEP 561 type checking +include pynobo/py.typed diff --git a/README.md b/README.md index 4433428..15a9c60 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,26 @@ not perform any I/O, and can safely be called from the event loop. * get_current_zone_temperature - Get the current temperature from (the first component in) a zone * get_zone_override_mode - Get the override mode for the zone +## Exceptions + +Errors raised by pynobo inherit from `PynoboError`: + +* `PynoboConnectionError` — TCP connection to the hub failed or was lost +* `PynoboHandshakeError` — the hub rejected the handshake (bad serial, wrong API version, etc.) +* `PynoboValidationError` — invalid parameters. Also inherits `ValueError` for backwards compatibility with callers + written against earlier versions. + ## Backwards compatibility Synchronous wrapper methods are available for compatibility with v1.1.2, but it is recommended to -switch to the async methods by initializing the hub with `synchronous=False`. Otherwise, initializing -will start the async event loop in a daemon thread, discover and connect to hub before returning as before. +switch to the async methods by initializing the hub with `synchronous=False`. + +> **Deprecated:** `synchronous=True` emits a `DeprecationWarning` since 1.9.0 and will be removed in +> pynobo 2.0. Migrate to the async API by calling `asyncio.run(hub.connect())` (or awaiting from an +> existing event loop) instead of relying on the daemon-thread wrapper. + +Otherwise, initializing will start the async event loop in a daemon thread, discover and connect to +hub before returning as before. import time from pynobo import nobo diff --git a/pynobo.py b/pynobo/__init__.py similarity index 85% rename from pynobo.py rename to pynobo/__init__.py index f6c3323..7e546f7 100644 --- a/pynobo.py +++ b/pynobo/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import collections from contextlib import suppress @@ -7,7 +9,7 @@ import threading import warnings import socket -from typing import Union +from typing import Any, Callable, Union _LOGGER = logging.getLogger(__name__) @@ -22,6 +24,23 @@ errno.ETIMEDOUT, # Happens if hub has not responded to handshake in 60 seconds, e.g. due to network issue ] + +class PynoboError(Exception): + """Base class for all pynobo errors.""" + + +class PynoboConnectionError(PynoboError): + """Raised when the TCP connection to the hub fails or is lost.""" + + +class PynoboHandshakeError(PynoboError): + """Raised when the hub rejects the handshake (bad serial, wrong version).""" + + +class PynoboValidationError(PynoboError, ValueError): + """Raised for invalid parameters. Inherits ValueError for back-compat.""" + + class nobo: """This is where all the Nobø Hub magic happens!""" @@ -175,34 +194,34 @@ def is_valid_datetime(timestamp: str): def time_is_quarter(minutes: str): return int(minutes) % 15 == 0 - def validate_temperature(temperature: Union[int, str]): + def validate_temperature(temperature: Union[int, str]) -> None: if type(temperature) not in (int, str): raise TypeError('Temperature must be integer or string') if isinstance(temperature, str) and not temperature.isdigit(): - raise ValueError(f'Temperature "{temperature}" must be digits') + raise PynoboValidationError(f'Temperature "{temperature}" must be digits') temperature_int = int(temperature) if temperature_int < 7: - raise ValueError(f'Min temperature is 7°C') + raise PynoboValidationError(f'Min temperature is 7°C') if temperature_int > 30: - raise ValueError(f'Max temperature is 30°C') + raise PynoboValidationError(f'Max temperature is 30°C') - def validate_week_profile(profile): + def validate_week_profile(profile: list[str]) -> None: if type(profile) != list: - raise ValueError("Week profile must be a list") + raise PynoboValidationError("Week profile must be a list") day_count=0 for i in profile: if len(i) != 5: - raise ValueError(f"Invalid week profile entry: {i}") + raise PynoboValidationError(f"Invalid week profile entry: {i}") time = datetime.datetime.strptime(i[0:4], "%H%M") if not time.minute % 15 == 0: - raise ValueError(f"Week profile entry not in whole quarters: {i}") + raise PynoboValidationError(f"Week profile entry not in whole quarters: {i}") # Last character is state (0=Eco, 1=Comfort, 2=Away, 4=Off) if not i[4] in "0124": - raise ValueError(f"Week profile entry contains invalid state, must be 0, 1, 2, or 4: {i}") + raise PynoboValidationError(f"Week profile entry contains invalid state, must be 0, 1, 2, or 4: {i}") if time.hour == 0 and time.minute == 0: day_count+=1 if day_count != 7: - raise ValueError("Week profile must contain exactly 7 entries for midnight (starting with 0000)") + raise PynoboValidationError("Week profile must contain exactly 7 entries for midnight (starting with 0000)") class Model: @@ -306,19 +325,19 @@ def has_temp_sensor(self) -> bool: class DiscoveryProtocol(asyncio.DatagramProtocol): """Protocol to discover Nobø Echohub on local network.""" - def __init__(self, serial = '', ip = None): + def __init__(self, serial: str = '', ip: str | None = None) -> None: """ :param serial: The last 3 digits of the Ecohub serial number or the complete 12 digit serial number :param ip: ip address to search for Ecohub at (default None) """ self.serial = serial self.ip = ip - self.hubs = set() + self.hubs: set[tuple[str, str]] = set() - def connection_made(self, transport: asyncio.transports.DatagramTransport): + def connection_made(self, transport: asyncio.transports.DatagramTransport) -> None: # type: ignore[override] self.transport = transport - def datagram_received(self, data: bytes, addr): + def datagram_received(self, data: bytes, addr: tuple[str | Any, ...]) -> None: msg = data.decode('utf-8') _LOGGER.info('broadcast received: %s from %s', msg, addr[0]) # Expected string “__NOBOHUB__123123123”, where 123123123 is replaced with the first 9 digits of the Hub’s serial number. @@ -339,7 +358,22 @@ def datagram_received(self, data: bytes, addr): if discover_ip and discover_serial: self.hubs.add( (discover_ip, discover_serial) ) - def __init__(self, serial, ip=None, discover=True, loop=None, synchronous=True, timezone: datetime.tzinfo=None): + hub_info: dict[str, Any] + zones: dict[str, dict[str, Any]] + components: dict[str, dict[str, Any]] + week_profiles: dict[str, dict[str, Any]] + overrides: dict[str, dict[str, Any]] + temperatures: dict[str, str] + + def __init__( + self, + serial: str, + ip: str | None = None, + discover: bool = True, + loop: asyncio.AbstractEventLoop | None = None, + synchronous: bool = True, + timezone: datetime.tzinfo | None = None, + ) -> None: """ Initialize logger and dictionaries. @@ -347,7 +381,8 @@ def __init__(self, serial, ip=None, discover=True, loop=None, synchronous=True, :param ip: IP address to search for Ecohub at (default None) :param discover: True/false for using UDP autodiscovery for the IP (default True) :param loop: Deprecated - :param synchronous: True/false for using the module synchronously. For backwards compatibility. + :param synchronous: True/false for using the module synchronously. Deprecated, will be removed in 2.0. + :param timezone: Timezone used for formatting timestamps (default None = local time) """ self.serial = serial @@ -358,11 +393,11 @@ def __init__(self, serial, ip=None, discover=True, loop=None, synchronous=True, synchronous=False self.timezone = timezone - self._callbacks = [] - self._reader = None - self._writer = None - self._keep_alive_task = None - self._socket_receive_task = None + self._callbacks: list[Callable[["nobo"], None]] = [] + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self._keep_alive_task: asyncio.Task[None] | None = None + self._socket_receive_task: asyncio.Task[None] | None = None self._received_all_info = False self.hub_info = {} @@ -373,6 +408,12 @@ def __init__(self, serial, ip=None, discover=True, loop=None, synchronous=True, self.temperatures = collections.OrderedDict() if synchronous: + warnings.warn( + "synchronous mode is deprecated and will be removed in pynobo 2.0; " + "use the async API with asyncio.run() or an existing event loop.", + DeprecationWarning, + stacklevel=2, + ) # Run asyncio in a separate thread try: loop = asyncio.get_running_loop() @@ -386,7 +427,7 @@ def __init__(self, serial, ip=None, discover=True, loop=None, synchronous=True, thread.setDaemon(True) thread.start() - def register_callback(self, callback=lambda *args, **kwargs: None): + def register_callback(self, callback: Callable[["nobo"], None] = lambda *args, **kwargs: None) -> None: """ Register a callback to notify updates to the hub state. The callback MUST be safe to call from the event loop. The nobo instance is passed to the callback function. Limit callbacks @@ -396,7 +437,7 @@ def register_callback(self, callback=lambda *args, **kwargs: None): """ self._callbacks.append(callback) - def deregister_callback(self, callback=lambda *args, **kwargs: None): + def deregister_callback(self, callback: Callable[["nobo"], None] = lambda *args, **kwargs: None) -> None: """ Deregister a previously registered callback. @@ -404,7 +445,7 @@ def deregister_callback(self, callback=lambda *args, **kwargs: None): """ self._callbacks.remove(callback) - async def connect(self): + async def connect(self) -> None: """Connect to Ecohub, either by scanning or directly.""" connected = False if self.discover: @@ -412,7 +453,7 @@ async def connect(self): discovered_hubs = await self.async_discover_hubs(serial=self.serial, ip=self.ip) if not discovered_hubs: _LOGGER.error('Failed to discover any Nobø Ecohubs') - raise Exception('Failed to discover any Nobø Ecohubs') + raise PynoboConnectionError('Failed to discover any Nobø Ecohubs') while discovered_hubs: (discover_ip, discover_serial) = discovered_hubs.pop() connected = await self.async_connect_hub(discover_ip, discover_serial) @@ -422,20 +463,20 @@ async def connect(self): # check if we have an IP if not self.ip: _LOGGER.error('Could not connect, no ip address provided') - raise ValueError('Could not connect, no ip address provided') + raise PynoboValidationError('Could not connect, no ip address provided') # check if we have a valid serial before we start connection if len(self.serial) != 12: _LOGGER.error('Could not connect, no valid serial number provided') - raise ValueError('Could not connect, no valid serial number provided') + raise PynoboValidationError('Could not connect, no valid serial number provided') connected = await self.async_connect_hub(self.ip, self.serial) if not connected: _LOGGER.error('Could not connect to Nobø Ecohub') - raise Exception(f'Failed to connect to Nobø Ecohub with serial: {self.serial} and ip: {self.ip}') + raise PynoboConnectionError(f'Failed to connect to Nobø Ecohub with serial: {self.serial} and ip: {self.ip}') - async def start(self): + async def start(self) -> None: """Discover Ecohub and start the TCP client.""" if not self._writer: @@ -446,7 +487,7 @@ async def start(self): self._socket_receive_task = asyncio.create_task(self.socket_receive()) _LOGGER.info('connected to Nobø Ecohub') - async def stop(self): + async def stop(self) -> None: """Stop the keep-alive and receiver tasks and close the connection to Nobø Ecohub.""" if self._keep_alive_task: self._keep_alive_task.cancel() @@ -459,7 +500,7 @@ async def stop(self): await self.close() _LOGGER.info('disconnected from Nobø Ecohub') - async def close(self): + async def close(self) -> None: """Close the connection to Nobø Ecohub.""" if self._writer: self._writer.close() @@ -468,7 +509,7 @@ async def close(self): self._writer = None _LOGGER.info('connection closed') - def connect_hub(self, ip, serial): + def connect_hub(self, ip: str, serial: str) -> bool: try: loop = asyncio.get_running_loop() except RuntimeError: @@ -476,7 +517,7 @@ def connect_hub(self, ip, serial): asyncio.set_event_loop(loop) return loop.run_until_complete(self.async_connect_hub(ip, serial)) - async def async_connect_hub(self, ip, serial): + async def async_connect_hub(self, ip: str, serial: str) -> bool: """ Attempt initial connection and handshake. @@ -484,7 +525,7 @@ async def async_connect_hub(self, ip, serial): :param serial: The complete 12 digit serial number of the hub to connect to """ if len(serial) != 12 or not serial.isdigit(): - raise ValueError(f'Invalid serial number: {serial}') + raise PynoboValidationError(f'Invalid serial number: {serial}') self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(ip, 27779), timeout=5) @@ -523,7 +564,7 @@ async def async_connect_hub(self, ip, serial): # Something went wrong... _LOGGER.error('Final handshake not as expected %s', response) await self.close() - raise Exception(f'Final handshake not as expected {response}') + raise PynoboHandshakeError(f'Final handshake not as expected {response}') if response[0] == nobo.API.REJECT: # This may not be the hub we are looking for @@ -538,9 +579,9 @@ async def async_connect_hub(self, ip, serial): # Unexpected response _LOGGER.error('connection to hub rejected: %s', response) - raise Exception(f'connection to hub rejected: {response}') + raise PynoboHandshakeError(f'connection to hub rejected: {response}') - async def reconnect_hub(self): + async def reconnect_hub(self) -> None: """Attempt to reconnect to the hub.""" _LOGGER.info('reconnecting to hub') @@ -584,7 +625,12 @@ async def reconnect_hub(self): _LOGGER.info('reconnected to Nobø Hub') @staticmethod - def discover_hubs(serial="", ip=None, autodiscover_wait=3.0, loop=None): + def discover_hubs( + serial: str = "", + ip: str | None = None, + autodiscover_wait: float = 3.0, + loop: asyncio.AbstractEventLoop | None = None, + ) -> set[tuple[str, str]]: if loop is not None: _LOGGER.warning("loop is deprecated") try: @@ -595,7 +641,13 @@ def discover_hubs(serial="", ip=None, autodiscover_wait=3.0, loop=None): return loop.run_until_complete(nobo.async_discover_hubs(serial, ip, autodiscover_wait)) @staticmethod - async def async_discover_hubs(serial="", ip=None, autodiscover_wait=3.0, loop=None, rediscover=False): + async def async_discover_hubs( + serial: str = "", + ip: str | None = None, + autodiscover_wait: float = 3.0, + loop: asyncio.AbstractEventLoop | None = None, + rediscover: bool = False, + ) -> set[tuple[str, str]]: """ Attempt to autodiscover Nobø Ecohubs on the local network. @@ -651,7 +703,7 @@ def _reuse_port() -> bool: pass return False - async def keep_alive(self, interval = 14): + async def keep_alive(self, interval: int = 14) -> None: """ Send a periodic handshake. Needs to be sent every < 30 sec, preferably every 14 seconds. @@ -663,7 +715,7 @@ async def keep_alive(self, interval = 14): if self._keep_alive: await self.async_send_command([nobo.API.HANDSHAKE]) - def _create_task(self, target): + def _create_task(self, target: Any) -> None: try: loop = asyncio.get_running_loop() except RuntimeError: @@ -671,10 +723,10 @@ def _create_task(self, target): asyncio.set_event_loop(loop) loop.call_soon_threadsafe(lambda: loop.create_task(target)) - def send_command(self, commands): + def send_command(self, commands: list[Any]) -> None: self._create_task(self.async_send_command(commands)) - async def async_send_command(self, commands): + async def async_send_command(self, commands: list[Any]) -> None: """ Send a list of command string(s) to the hub. @@ -698,13 +750,13 @@ async def async_send_command(self, commands): _LOGGER.info('lost connection to hub (%s)', e) await self.close() - async def _get_initial_data(self): + async def _get_initial_data(self) -> None: self._received_all_info = False await self.async_send_command([nobo.API.GET_ALL_INFO]) while not self._received_all_info: self.response_handler(await self.get_response()) - async def get_response(self): + async def get_response(self) -> list[str]: """ Get a response string from the hub and reformat string list before returning it. @@ -721,7 +773,7 @@ async def get_response(self): _LOGGER.debug('received: %s', response) return response - async def socket_receive(self): + async def socket_receive(self) -> None: try: while True: try: @@ -752,7 +804,7 @@ async def socket_receive(self): # Just disconnect (instead of risking an infinite reconnect loop) await self.stop() - def response_handler(self, response): + def response_handler(self, response: list[str]) -> None: """ Handle the response(s) from the hub and update the dictionaries accordingly. @@ -846,10 +898,26 @@ def response_handler(self, response): _LOGGER.warning('behavior undefined for this response: %s', response) warnings.warn(f'behavior undefined for this response: {response}') #overkill? - def create_override(self, mode, type, target_type, target_id='-1', end_time='-1', start_time='-1'): + def create_override( + self, + mode: str, + type: str, + target_type: str, + target_id: str = '-1', + end_time: str = '-1', + start_time: str = '-1', + ) -> None: self._create_task(self.async_create_override(mode, type, target_type, target_id, end_time, start_time)) - async def async_create_override(self, mode, type, target_type, target_id='-1', end_time='-1', start_time='-1'): + async def async_create_override( + self, + mode: str, + type: str, + target_type: str, + target_id: str = '-1', + end_time: str = '-1', + start_time: str = '-1', + ) -> None: """ Override hub/zones/components. Use OVERRIDE_MODE_NORMAL to disable an existing override. @@ -861,23 +929,23 @@ async def async_create_override(self, mode, type, target_type, target_id='-1', e :param start_time: the start time (default -1), format YYYYMMDDhhmm, where mm must be in whole 15 minutes """ if not mode in nobo.API.OVERRIDE_MODES: - raise ValueError(f'Unknown override mode {mode}') + raise PynoboValidationError(f'Unknown override mode {mode}') if not type in nobo.API.OVERRIDE_TYPES: - raise ValueError(f'Unknown override type {type}') + raise PynoboValidationError(f'Unknown override type {type}') if not target_type in nobo.API.OVERRIDE_TARGETS: - raise ValueError(f'Unknown override target type {target_type}') + raise PynoboValidationError(f'Unknown override target type {target_type}') if target_id != '-1' and not target_id in self.zones: - raise ValueError(f'Unknown override target {target_id}') + raise PynoboValidationError(f'Unknown override target {target_id}') if end_time != '-1': if not nobo.API.is_valid_datetime(end_time): - raise ValueError(f'Illegal end_time {end_time}: Cannot parse') + raise PynoboValidationError(f'Illegal end_time {end_time}: Cannot parse') if not nobo.API.time_is_quarter(end_time[-2:]): - raise ValueError(f'Illegal end_time {end_time}: Must be in whole 15 minutes') + raise PynoboValidationError(f'Illegal end_time {end_time}: Must be in whole 15 minutes') if start_time != '-1': if not nobo.API.is_valid_datetime(start_time): - raise ValueError(f'Illegal start_time: {start_time}: Cannot parse') + raise PynoboValidationError(f'Illegal start_time: {start_time}: Cannot parse') if not nobo.API.time_is_quarter(end_time[-2:]): - raise ValueError(f'Illegal start_time {end_time}: Must be in whole 15 minutes') + raise PynoboValidationError(f'Illegal start_time {end_time}: Must be in whole 15 minutes') command = [nobo.API.ADD_OVERRIDE, '1', mode, type, end_time, start_time, target_type, target_id] await self.async_send_command(command) for o in self.overrides: # Save override before command has finished executing @@ -885,10 +953,26 @@ async def async_create_override(self, mode, type, target_type, target_id='-1', e self.overrides[o]['mode'] = mode self.overrides[o]['type'] = type - def update_zone(self, zone_id, name=None, week_profile_id=None, temp_comfort_c=None, temp_eco_c=None, override_allowed=None): + def update_zone( + self, + zone_id: str, + name: str | None = None, + week_profile_id: str | None = None, + temp_comfort_c: int | str | None = None, + temp_eco_c: int | str | None = None, + override_allowed: str | None = None, + ) -> None: self._create_task(self.async_update_zone(zone_id, name, week_profile_id, temp_comfort_c, temp_eco_c, override_allowed)) - async def async_update_zone(self, zone_id, name=None, week_profile_id=None, temp_comfort_c=None, temp_eco_c=None, override_allowed=None): + async def async_update_zone( + self, + zone_id: str, + name: str | None = None, + week_profile_id: str | None = None, + temp_comfort_c: int | str | None = None, + temp_eco_c: int | str | None = None, + override_allowed: str | None = None, + ) -> None: """ Update the name, week profile, temperature or override allowing for a zone. @@ -901,7 +985,7 @@ async def async_update_zone(self, zone_id, name=None, week_profile_id=None, temp """ if not zone_id in self.zones: - raise ValueError(f'Unknown zone id {zone_id}') + raise PynoboValidationError(f'Unknown zone id {zone_id}') # Initialize command with the current zone settings command = [nobo.API.UPDATE_ZONE] + list(self.zones[zone_id].values()) @@ -910,11 +994,11 @@ async def async_update_zone(self, zone_id, name=None, week_profile_id=None, temp if name: name = name.replace(" ", "\u00A0") if len(name.encode('utf-8')) > 100: - raise ValueError(f'Zone name "{name}" too long (max 100 bytes when encoded as UTF-8)') + raise PynoboValidationError(f'Zone name "{name}" too long (max 100 bytes when encoded as UTF-8)') command[2] = name if week_profile_id: if not week_profile_id in self.week_profiles: - raise ValueError(f'Unknown week profile id {week_profile_id}') + raise PynoboValidationError(f'Unknown week profile id {week_profile_id}') command[3] = week_profile_id if temp_comfort_c: nobo.API.validate_temperature(temp_comfort_c) @@ -926,15 +1010,15 @@ async def async_update_zone(self, zone_id, name=None, week_profile_id=None, temp self.zones[zone_id]['temp_eco_c'] = temp_eco_c # Save setting before sending command if override_allowed: if override_allowed != nobo.API.OVERRIDE_NOT_ALLOWED and override_allowed != nobo.API.OVERRIDE_ALLOWED: - raise ValueError(f'Illegal value for override allowed: {override_allowed}') + raise PynoboValidationError(f'Illegal value for override allowed: {override_allowed}') command[6] = override_allowed if int(command[4]) < int(command[5]): - raise ValueError(f'Comfort temperature({command[4]}°C) cannot be less than eco temperature({command[5]}°C)') + raise PynoboValidationError(f'Comfort temperature({command[4]}°C) cannot be less than eco temperature({command[5]}°C)') await self.async_send_command(command) - async def async_add_week_profile(self, name, profile=None): + async def async_add_week_profile(self, name: str, profile: list[str] | None = None) -> None: """ Add the name and profile parameter for a week. @@ -953,14 +1037,19 @@ async def async_add_week_profile(self, name, profile=None): converted_profile =','.join(profile) name = name.replace(" ", "\u00A0") if len(name.encode('utf-8')) > 100: - raise ValueError(f'Zone name "{name}" too long (max 100 bytes when encoded as UTF-8)') + raise PynoboValidationError(f'Zone name "{name}" too long (max 100 bytes when encoded as UTF-8)') command = [nobo.API.ADD_WEEK_PROFILE] + [week_profile_id] + [name] + [converted_profile] await self.async_send_command(command) - async def async_update_week_profile(self, week_profile_id: str, name=None, profile=None): + async def async_update_week_profile( + self, + week_profile_id: str, + name: str | None = None, + profile: list[str] | None = None, + ) -> None: """ Update the name and profile parameter for a week. @@ -970,14 +1059,14 @@ async def async_update_week_profile(self, week_profile_id: str, name=None, profi """ if week_profile_id not in self.week_profiles: - raise ValueError(f"Unknown week profile {week_profile_id}") + raise PynoboValidationError(f"Unknown week profile {week_profile_id}") if name is None and profile is None: - raise ValueError("Set at least name or profile to update") + raise PynoboValidationError("Set at least name or profile to update") if name: name = name.replace(" ", "\u00A0") if len(name.encode('utf-8')) > 100: - raise ValueError(f'Zone name "{name}" too long (max 100 bytes when encoded as UTF-8)') + raise PynoboValidationError(f'Zone name "{name}" too long (max 100 bytes when encoded as UTF-8)') else: name = self.week_profiles[week_profile_id]["name"] @@ -989,7 +1078,7 @@ async def async_update_week_profile(self, week_profile_id: str, name=None, profi command = [nobo.API.UPDATE_WEEK_PROFILE, week_profile_id, name, ','.join(profile)] await self.async_send_command(command) - async def async_remove_week_profile(self, week_profile_id: str): + async def async_remove_week_profile(self, week_profile_id: str) -> None: """ Remove the week profile. @@ -997,10 +1086,10 @@ async def async_remove_week_profile(self, week_profile_id: str): """ if week_profile_id not in self.week_profiles: - raise ValueError(f"Unknown week profile {week_profile_id}") + raise PynoboValidationError(f"Unknown week profile {week_profile_id}") if week_profile_id in (v['week_profile_id'] for k, v in self.zones.items()): - raise ValueError(f"Week profile {week_profile_id} in use, can not remove") + raise PynoboValidationError(f"Week profile {week_profile_id} in use, can not remove") name = self.week_profiles[week_profile_id]["name"] profile = self.week_profiles[week_profile_id]["profile"] @@ -1008,7 +1097,7 @@ async def async_remove_week_profile(self, week_profile_id: str): command = [nobo.API.REMOVE_WEEK_PROFILE, week_profile_id, name, ','.join(profile)] await self.async_send_command(command) - def get_week_profile_status(self, week_profile_id, dt: datetime.datetime=None): + def get_week_profile_status(self, week_profile_id: str, dt: datetime.datetime | None = None) -> str: """ Get the status of a week profile at a certain time in the week. Monday is day 0. @@ -1040,7 +1129,7 @@ def get_week_profile_status(self, week_profile_id, dt: datetime.datetime=None): ) return nobo.API.DICT_WEEK_PROFILE_STATUS_TO_NAME[status] - def get_zone_override_mode(self, zone_id): + def get_zone_override_mode(self, zone_id: str) -> str: """ Get the override mode of a zone. @@ -1064,7 +1153,7 @@ def get_zone_override_mode(self, zone_id): _LOGGER.debug('Current override for zone %s is %s', self.zones[zone_id]['name'], mode) return mode - def get_current_zone_mode(self, zone_id, now: datetime.datetime=None): + def get_current_zone_mode(self, zone_id: str, now: datetime.datetime | None = None) -> str: """ Get the mode of a zone at a certain time. If the zone is overridden only now is possible. @@ -1087,7 +1176,7 @@ def get_current_zone_mode(self, zone_id, now: datetime.datetime=None): current_mode) return current_mode - def get_current_component_temperature(self, serial): + def get_current_component_temperature(self, serial: str) -> str | None: """ Get the current temperature from a component. @@ -1107,7 +1196,7 @@ def get_current_component_temperature(self, serial): return current_temperature # Function to get (first) temperature in a zone - def get_current_zone_temperature(self, zone_id): + def get_current_zone_temperature(self, zone_id: str) -> str | None: """ Get the current temperature from (the first component in) a zone. diff --git a/pynobo/py.typed b/pynobo/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7c0185c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "pynobo" +version = "1.8.1" +description = "Nobø Hub / Nobø Energy Control TCP/IP Interface" +readme = "README.md" +license = { text = "GPL-3.0-or-later" } +authors = [ + { name = "echoromeo, capelevy, oyvindwe" }, +] +keywords = ["hvac", "nobø", "heating", "automation"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Home Automation", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Typing :: Typed", +] +requires-python = ">=3.10, <4" + +[project.urls] +Homepage = "https://github.com/echoromeo/pynobo" +Source = "https://github.com/echoromeo/pynobo" +"Bug Reports" = "https://github.com/echoromeo/pynobo/issues" + +[tool.setuptools] +packages = ["pynobo"] +zip-safe = false +include-package-data = true + +[tool.setuptools.package-data] +pynobo = ["py.typed"] diff --git a/setup.py b/setup.py index a3122de..c4a91dd 100644 --- a/setup.py +++ b/setup.py @@ -93,14 +93,16 @@ # # py_modules=["my_module"], # -# packages=find_packages(exclude=['contrib', 'docs', 'tests']), # Required - py_modules=["pynobo"], + packages=["pynobo"], + package_data={"pynobo": ["py.typed"]}, + include_package_data=True, + zip_safe=False, # Specify which Python versions you support. In contrast to the # 'Programming Language' classifiers above, 'pip install' will check this # and refuse to install the project if the version does not match. See # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires - python_requires='>=3.7, <4', + python_requires='>=3.10, <4', # This field lists other packages that your project depends on to run. # Any package you put here will be installed by pip when your project is diff --git a/test_pynobo.py b/test_pynobo.py index fe4a0cf..f10e292 100644 --- a/test_pynobo.py +++ b/test_pynobo.py @@ -1,6 +1,13 @@ +import pathlib import unittest -from pynobo import nobo +from pynobo import ( + PynoboConnectionError, + PynoboError, + PynoboHandshakeError, + PynoboValidationError, + nobo, +) class TestValidation(unittest.TestCase): @@ -45,5 +52,29 @@ def test_validate_week_profile(self): with self.assertRaisesRegex(ValueError, "not in whole quarters"): nobo.API.validate_week_profile(['00000','01231','00000','00000','00000','00000','00000','00000']) + +class TestExceptionHierarchy(unittest.TestCase): + + def test_validation_error_raised_and_inherits_value_error(self): + with self.assertRaises(PynoboValidationError): + nobo.API.validate_temperature(6) + # back-compat: callers catching ValueError still work + with self.assertRaises(ValueError): + nobo.API.validate_temperature(6) + + def test_all_errors_inherit_base(self): + self.assertTrue(issubclass(PynoboConnectionError, PynoboError)) + self.assertTrue(issubclass(PynoboHandshakeError, PynoboError)) + self.assertTrue(issubclass(PynoboValidationError, PynoboError)) + self.assertTrue(issubclass(PynoboValidationError, ValueError)) + + +class TestPyTypedMarker(unittest.TestCase): + + def test_py_typed_file_is_present_in_source(self): + marker = pathlib.Path(__file__).parent / "pynobo" / "py.typed" + self.assertTrue(marker.is_file(), f"{marker} is missing") + + if __name__ == '__main__': unittest.main() From 1a2b09ed0e5ca2e11f117c1366c7a55bfdeb5e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Mon, 20 Apr 2026 00:28:47 +0200 Subject: [PATCH 2/2] Address review comments --- README.md | 27 ++++++++++------ pynobo/__init__.py | 78 +++++++++++++++++++++++++++++++++++++--------- test_pynobo.py | 8 +++++ 3 files changed, 90 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 15a9c60..40cb9a3 100644 --- a/README.md +++ b/README.md @@ -115,15 +115,24 @@ Errors raised by pynobo inherit from `PynoboError`: ## Backwards compatibility -Synchronous wrapper methods are available for compatibility with v1.1.2, but it is recommended to -switch to the async methods by initializing the hub with `synchronous=False`. - -> **Deprecated:** `synchronous=True` emits a `DeprecationWarning` since 1.9.0 and will be removed in -> pynobo 2.0. Migrate to the async API by calling `asyncio.run(hub.connect())` (or awaiting from an -> existing event loop) instead of relying on the daemon-thread wrapper. - -Otherwise, initializing will start the async event loop in a daemon thread, discover and connect to -hub before returning as before. +**Deprecated as of 1.9.0, to be removed in 2.0.** The synchronous wrapper API is still available for +compatibility with v1.1.2, but every sync entry point now emits a `DeprecationWarning`. Migrate to +the async API — initialize with `synchronous=False` and call the `async_*` methods from an event +loop (or `asyncio.run(...)`). + +> The following APIs emit a `DeprecationWarning`: +> +> - `synchronous=True` in `nobo(...)` — the daemon-thread wrapper. Use the async API and +> `asyncio.run(hub.connect())` (or await from an existing event loop) instead. +> - `nobo.connect_hub(ip, serial)` — use `await hub.async_connect_hub(ip, serial)`. +> - `nobo.discover_hubs(...)` — use `await nobo.async_discover_hubs(...)`. +> - `hub.send_command(commands)` — use `await hub.async_send_command(commands)`. +> - `hub.create_override(...)` — use `await hub.async_create_override(...)`. +> - `hub.update_zone(...)` — use `await hub.async_update_zone(...)`. +> - `loop=` parameter in `nobo(...)` and `nobo.async_discover_hubs(...)`. + +While `synchronous=True` remains supported in 1.x, initializing this way starts the async event loop +in a daemon thread, discovers and connects to the hub before returning as before. import time from pynobo import nobo diff --git a/pynobo/__init__.py b/pynobo/__init__.py index 7e546f7..9562d9e 100644 --- a/pynobo/__init__.py +++ b/pynobo/__init__.py @@ -37,8 +37,8 @@ class PynoboHandshakeError(PynoboError): """Raised when the hub rejects the handshake (bad serial, wrong version).""" -class PynoboValidationError(PynoboError, ValueError): - """Raised for invalid parameters. Inherits ValueError for back-compat.""" +class PynoboValidationError(PynoboError, ValueError, TypeError): + """Raised for invalid parameters. Inherits ValueError and TypeError for back-compat.""" class nobo: @@ -196,7 +196,7 @@ def time_is_quarter(minutes: str): def validate_temperature(temperature: Union[int, str]) -> None: if type(temperature) not in (int, str): - raise TypeError('Temperature must be integer or string') + raise PynoboValidationError('Temperature must be integer or string') if isinstance(temperature, str) and not temperature.isdigit(): raise PynoboValidationError(f'Temperature "{temperature}" must be digits') temperature_int = int(temperature) @@ -389,7 +389,12 @@ def __init__( self.ip = ip self.discover = discover if loop is not None: - _LOGGER.warning("loop is deprecated. Use synchronous=False instead.") + warnings.warn( + "the loop parameter is deprecated and will be removed in pynobo 2.0; " + "use synchronous=False and manage the event loop yourself.", + DeprecationWarning, + stacklevel=2, + ) synchronous=False self.timezone = timezone @@ -510,6 +515,12 @@ async def close(self) -> None: _LOGGER.info('connection closed') def connect_hub(self, ip: str, serial: str) -> bool: + warnings.warn( + "nobo.connect_hub is deprecated and will be removed in pynobo 2.0; " + "use `await hub.async_connect_hub(ip, serial)` instead.", + DeprecationWarning, + stacklevel=2, + ) try: loop = asyncio.get_running_loop() except RuntimeError: @@ -527,14 +538,20 @@ async def async_connect_hub(self, ip: str, serial: str) -> bool: if len(serial) != 12 or not serial.isdigit(): raise PynoboValidationError(f'Invalid serial number: {serial}') - self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(ip, 27779), timeout=5) + try: + self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(ip, 27779), timeout=5) + except (OSError, asyncio.TimeoutError) as e: + raise PynoboConnectionError(f'Failed to connect to Nobø Ecohub at {ip}') from e # start handshake: "HELLO \r" now = datetime.datetime.now(self.timezone).strftime('%Y%m%d%H%M%S') await self.async_send_command([nobo.API.START, nobo.API.VERSION, serial, now]) # receive the response data (4096 is recommended buffer size) - response = await asyncio.wait_for(self.get_response(), timeout=5) + try: + response = await asyncio.wait_for(self.get_response(), timeout=5) + except asyncio.TimeoutError as e: + raise PynoboConnectionError(f'Timed out waiting for handshake response from {ip}') from e _LOGGER.debug('first handshake response: %s', response) # successful response is "HELLO \r" @@ -547,7 +564,10 @@ async def async_connect_hub(self, ip: str, serial: str) -> bool: # send/receive handshake complete await self.async_send_command([nobo.API.HANDSHAKE]) - response = await asyncio.wait_for(self.get_response(), timeout=5) + try: + response = await asyncio.wait_for(self.get_response(), timeout=5) + except asyncio.TimeoutError as e: + raise PynoboConnectionError(f'Timed out waiting for final handshake response from {ip}') from e _LOGGER.debug('second handshake response: %s', response) if response[0] == nobo.API.HANDSHAKE: @@ -556,7 +576,10 @@ async def async_connect_hub(self, ip: str, serial: str) -> bool: self.hub_serial = serial # Get initial data - await asyncio.wait_for(self._get_initial_data(), timeout=5) + try: + await asyncio.wait_for(self._get_initial_data(), timeout=5) + except asyncio.TimeoutError as e: + raise PynoboConnectionError(f'Timed out waiting for initial data from {ip}') from e for callback in self._callbacks: callback(self) return True @@ -606,7 +629,7 @@ async def reconnect_hub(self) -> None: discovered_hubs.add( (discover_ip, discover_serial) ) await asyncio.sleep(1) else: - raise e + raise PynoboConnectionError(f'Failed to reconnect to Nobø Ecohub at {discover_ip}: {e}') from e else: connected = False while not connected: @@ -619,7 +642,7 @@ async def reconnect_hub(self) -> None: if e.errno in RECONNECT_ERRORS: _LOGGER.debug('Ignoring %s', e) else: - raise e + raise PynoboConnectionError(f'Failed to reconnect to Nobø Ecohub at {self.ip}: {e}') from e self._keep_alive = True _LOGGER.info('reconnected to Nobø Hub') @@ -631,8 +654,12 @@ def discover_hubs( autodiscover_wait: float = 3.0, loop: asyncio.AbstractEventLoop | None = None, ) -> set[tuple[str, str]]: - if loop is not None: - _LOGGER.warning("loop is deprecated") + warnings.warn( + "nobo.discover_hubs is deprecated and will be removed in pynobo 2.0; " + "use `await nobo.async_discover_hubs(...)` instead.", + DeprecationWarning, + stacklevel=2, + ) try: loop = asyncio.get_running_loop() except RuntimeError: @@ -675,7 +702,11 @@ async def async_discover_hubs( """ if loop is not None: - _LOGGER.warning("loop is deprecated.") + warnings.warn( + "the loop parameter is deprecated and will be removed in pynobo 2.0.", + DeprecationWarning, + stacklevel=2, + ) transport, protocol = await asyncio.get_running_loop().create_datagram_endpoint( lambda: nobo.DiscoveryProtocol(serial, ip), local_addr=('0.0.0.0', 10000), @@ -724,6 +755,12 @@ def _create_task(self, target: Any) -> None: loop.call_soon_threadsafe(lambda: loop.create_task(target)) def send_command(self, commands: list[Any]) -> None: + warnings.warn( + "nobo.send_command is deprecated and will be removed in pynobo 2.0; " + "use `await hub.async_send_command(commands)` instead.", + DeprecationWarning, + stacklevel=2, + ) self._create_task(self.async_send_command(commands)) async def async_send_command(self, commands: list[Any]) -> None: @@ -768,7 +805,7 @@ async def get_response(self) -> list[str]: except ConnectionError as e: _LOGGER.info('lost connection to hub (%s)', e) await self.close() - raise e + raise PynoboConnectionError(f'Lost connection to Nobø Ecohub: {e}') from e response = message.decode('utf-8').split(' ') _LOGGER.debug('received: %s', response) return response @@ -795,6 +832,7 @@ async def socket_receive(self) -> None: _LOGGER.info('Reconnecting due to %s', e) await self.reconnect_hub() else: + # Caught by the outer `except Exception` below, so don't need to wrap. raise e except asyncio.CancelledError: _LOGGER.debug('socket_receive stopped') @@ -907,6 +945,12 @@ def create_override( end_time: str = '-1', start_time: str = '-1', ) -> None: + warnings.warn( + "nobo.create_override is deprecated and will be removed in pynobo 2.0; " + "use `await hub.async_create_override(...)` instead.", + DeprecationWarning, + stacklevel=2, + ) self._create_task(self.async_create_override(mode, type, target_type, target_id, end_time, start_time)) async def async_create_override( @@ -962,6 +1006,12 @@ def update_zone( temp_eco_c: int | str | None = None, override_allowed: str | None = None, ) -> None: + warnings.warn( + "nobo.update_zone is deprecated and will be removed in pynobo 2.0; " + "use `await hub.async_update_zone(...)` instead.", + DeprecationWarning, + stacklevel=2, + ) self._create_task(self.async_update_zone(zone_id, name, week_profile_id, temp_comfort_c, temp_eco_c, override_allowed)) async def async_update_zone( diff --git a/test_pynobo.py b/test_pynobo.py index f10e292..1e899a2 100644 --- a/test_pynobo.py +++ b/test_pynobo.py @@ -62,11 +62,19 @@ def test_validation_error_raised_and_inherits_value_error(self): with self.assertRaises(ValueError): nobo.API.validate_temperature(6) + def test_validation_error_raised_for_type_check(self): + with self.assertRaises(PynoboValidationError): + nobo.API.validate_temperature(0.0) + # back-compat: callers catching TypeError still work + with self.assertRaises(TypeError): + nobo.API.validate_temperature(0.0) + def test_all_errors_inherit_base(self): self.assertTrue(issubclass(PynoboConnectionError, PynoboError)) self.assertTrue(issubclass(PynoboHandshakeError, PynoboError)) self.assertTrue(issubclass(PynoboValidationError, PynoboError)) self.assertTrue(issubclass(PynoboValidationError, ValueError)) + self.assertTrue(issubclass(PynoboValidationError, TypeError)) class TestPyTypedMarker(unittest.TestCase):