Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ 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

### Connection state

Consumers can observe when the hub connects, disconnects, or reconnects. The
`connected` property reflects the current state; register a callback to be
notified on every transition. Callbacks MUST be safe to call from the event
loop; exceptions they raise are logged and swallowed.

# Called with (hub, True) on connect/reconnect, (hub, False) on disconnect
def on_connection_state(hub, connected):
print("connected" if connected else "disconnected")

hub.register_connection_callback(on_connection_state)
await hub.connect()
assert hub.connected
# ...later, to stop listening:
hub.deregister_connection_callback(on_connection_state)

## Exceptions

Errors raised by pynobo inherit from `PynoboError`:
Expand Down
33 changes: 33 additions & 0 deletions pynobo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,8 @@ def __init__(
self.timezone = timezone

self._callbacks: list[Callable[["nobo"], None]] = []
self._connection_callbacks: list[Callable[["nobo", bool], None]] = []
self._connected: bool = False
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._keep_alive_task: asyncio.Task[None] | None = None
Expand Down Expand Up @@ -450,6 +452,31 @@ def deregister_callback(self, callback: Callable[["nobo"], None] = lambda *args,
"""
self._callbacks.remove(callback)

@property
def connected(self) -> bool:
"""Whether the hub is currently connected."""
return self._connected

def register_connection_callback(self, callback: Callable[["nobo", bool], None]) -> None:
"""Register a callback invoked on connection-state transitions."""
self._connection_callbacks.append(callback)

def deregister_connection_callback(self, callback: Callable[["nobo", bool], None]) -> None:
"""Deregister a previously registered connection-state callback."""
if callback in self._connection_callbacks:
self._connection_callbacks.remove(callback)

def _set_connected(self, value: bool) -> None:
"""Update connection state and fire callbacks on transitions."""
if self._connected == value:
return
self._connected = value
for cb in list(self._connection_callbacks):
try:
cb(self, value)
except Exception:
_LOGGER.exception("Connection-state callback raised")

async def connect(self) -> None:
"""Connect to Ecohub, either by scanning or directly."""
connected = False
Expand Down Expand Up @@ -481,6 +508,8 @@ async def connect(self) -> None:
_LOGGER.error('Could not connect to Nobø Ecohub')
raise PynoboConnectionError(f'Failed to connect to Nobø Ecohub with serial: {self.serial} and ip: {self.ip}')

self._set_connected(True)

async def start(self) -> None:
"""Discover Ecohub and start the TCP client."""

Expand Down Expand Up @@ -513,6 +542,7 @@ async def close(self) -> None:
await self._writer.wait_closed()
self._writer = None
_LOGGER.info('connection closed')
self._set_connected(False)

def connect_hub(self, ip: str, serial: str) -> bool:
warnings.warn(
Expand Down Expand Up @@ -645,6 +675,7 @@ async def reconnect_hub(self) -> None:
raise PynoboConnectionError(f'Failed to reconnect to Nobø Ecohub at {self.ip}: {e}') from e

self._keep_alive = True
self._set_connected(True)
_LOGGER.info('reconnected to Nobø Hub')

@staticmethod
Expand Down Expand Up @@ -826,10 +857,12 @@ async def socket_receive(self) -> None:
callback(self)
except (asyncio.IncompleteReadError) as e:
_LOGGER.info('Reconnecting due to %s', e)
self._set_connected(False)
await self.reconnect_hub()
except (OSError) as e:
if e.errno in RECONNECT_ERRORS:
_LOGGER.info('Reconnecting due to %s', e)
self._set_connected(False)
await self.reconnect_hub()
else:
# Caught by the outer `except Exception` below, so don't need to wrap.
Expand Down
52 changes: 52 additions & 0 deletions test_pynobo.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,57 @@ def test_py_typed_file_is_present_in_source(self):
self.assertTrue(marker.is_file(), f"{marker} is missing")


class TestConnectionStateAPI(unittest.TestCase):

def _make_hub(self):
return nobo('123', discover=False, synchronous=False)

def test_initial_state_is_disconnected(self):
hub = self._make_hub()
events = []
hub.register_connection_callback(lambda h, s: events.append(s))
self.assertFalse(hub.connected)
self.assertEqual(events, [])

def test_transition_fires_callback_once(self):
hub = self._make_hub()
events = []
hub.register_connection_callback(lambda h, s: events.append((h is hub, s)))
hub._set_connected(True)
self.assertTrue(hub.connected)
self.assertEqual(events, [(True, True)])

def test_transitions_are_idempotent(self):
hub = self._make_hub()
events = []
hub.register_connection_callback(lambda h, s: events.append(s))
hub._set_connected(True)
hub._set_connected(True)
hub._set_connected(False)
hub._set_connected(False)
self.assertEqual(events, [True, False])

def test_deregister_stops_callback(self):
hub = self._make_hub()
events = []
cb = lambda h, s: events.append(s)
hub.register_connection_callback(cb)
hub._set_connected(True)
hub.deregister_connection_callback(cb)
hub._set_connected(False)
self.assertEqual(events, [True])

def test_callback_exception_does_not_break_dispatch(self):
hub = self._make_hub()
events = []
def boom(h, s):
raise RuntimeError("boom")
hub.register_connection_callback(boom)
hub.register_connection_callback(lambda h, s: events.append(s))
hub._set_connected(True)
self.assertEqual(events, [True])
self.assertTrue(hub.connected)


if __name__ == '__main__':
unittest.main()
Loading