|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import asyncio |
| 6 | +import itertools |
6 | 7 | import logging |
7 | 8 | import struct |
8 | 9 | from asyncio import StreamReader, StreamWriter |
9 | | -from typing import Awaitable |
| 10 | +from typing import Awaitable, Callable, Dict, Iterator, Optional, Set |
10 | 11 |
|
11 | 12 | from google.protobuf.message import DecodeError |
12 | 13 | from google.protobuf.message import Message as ProtobufMessage |
@@ -39,141 +40,219 @@ class EventBus: |
39 | 40 | """An event bus for async replies.""" |
40 | 41 |
|
41 | 42 | def __init__(self): |
42 | | - self.listeners = {} |
| 43 | + self._listeners: Dict[int, Set[asyncio.Future]] = {} |
43 | 44 |
|
44 | | - def add_listener(self, event_name, future): |
| 45 | + @property |
| 46 | + def listeners(self) -> Dict[int, Set[asyncio.Future]]: |
| 47 | + """Expose listeners for diagnostic purposes (primarily tests).""" |
| 48 | + return self._listeners |
| 49 | + |
| 50 | + def add_listener(self, event_name: int, future: asyncio.Future): |
45 | 51 | """Add a listener to the event bus.""" |
46 | 52 | _LOGGER.debug("Adding listener for event %s", event_name) |
47 | | - if not self.listeners.get(event_name, None): |
48 | | - self.listeners[event_name] = {future} |
49 | | - else: |
50 | | - self.listeners[event_name].add(future) |
| 53 | + self._listeners.setdefault(event_name, set()).add(future) |
51 | 54 |
|
52 | | - def emit(self, event_name, event): |
| 55 | + def emit(self, event_name: int, event): |
53 | 56 | """Emit an event to the event bus.""" |
54 | 57 | _LOGGER.debug("Emitting for event %s", event_name) |
55 | | - futures = self.listeners.get(event_name, []) |
| 58 | + futures = self._listeners.pop(event_name, set()) |
56 | 59 | for future in futures: |
| 60 | + if future.done(): |
| 61 | + continue |
57 | 62 | if isinstance(event, Exception): |
58 | 63 | future.set_exception(event) |
59 | 64 | else: |
60 | 65 | future.set_result(event) |
61 | | - del self.listeners[event_name] |
| 66 | + |
| 67 | + def fail_all(self, exc: Exception): |
| 68 | + """Fail all pending listeners with the provided exception.""" |
| 69 | + pending = list(self._listeners.values()) |
| 70 | + self._listeners.clear() |
| 71 | + for futures in pending: |
| 72 | + for future in futures: |
| 73 | + if future.done(): |
| 74 | + continue |
| 75 | + future.set_exception(exc) |
62 | 76 |
|
63 | 77 |
|
64 | 78 | class Bridge: |
65 | 79 | """ComfoConnect LAN C API.""" |
66 | 80 |
|
67 | 81 | PORT = 56747 |
68 | 82 |
|
69 | | - def __init__(self, host: str, uuid: str, loop=None): |
| 83 | + def __init__(self, host: str, uuid: str, loop: Optional[asyncio.AbstractEventLoop] = None): |
70 | 84 | self.host: str = host |
71 | 85 | self.uuid: str = uuid |
72 | | - self._local_uuid: str = None |
| 86 | + self._local_uuid: Optional[str] = None |
73 | 87 |
|
74 | | - self._reader: StreamReader = None |
75 | | - self._writer: StreamWriter = None |
76 | | - self._reference = None |
| 88 | + self._reader: Optional[StreamReader] = None |
| 89 | + self._writer: Optional[StreamWriter] = None |
| 90 | + self._reference: Optional[Iterator[int]] = None |
77 | 91 |
|
78 | | - self._event_bus: EventBus = None |
| 92 | + self._event_bus: Optional[EventBus] = None |
79 | 93 |
|
80 | | - self.__sensor_callback_fn: callable = None |
81 | | - self.__alarm_callback_fn: callable = None |
| 94 | + self.__sensor_callback_fn: Optional[Callable[[int, int], None]] = None |
| 95 | + self.__alarm_callback_fn: Optional[Callable[[int, ProtobufMessage], None]] = None |
82 | 96 |
|
83 | | - self._loop = loop or asyncio.get_running_loop() |
| 97 | + self._loop: Optional[asyncio.AbstractEventLoop] = loop |
| 98 | + self._read_task = None |
84 | 99 |
|
85 | 100 | def __repr__(self): |
86 | 101 | return f"<Bridge {self.host}, UID={self.uuid}>" |
87 | 102 |
|
88 | | - def set_sensor_callback(self, callback: callable): |
| 103 | + def set_sensor_callback(self, callback: Optional[Callable[[int, int], None]]): |
89 | 104 | """Set a callback to be called when a message is received.""" |
90 | 105 | self.__sensor_callback_fn = callback |
91 | 106 |
|
92 | | - def set_alarm_callback(self, callback: callable): |
| 107 | + def set_alarm_callback(self, callback: Optional[Callable[[int, ProtobufMessage], None]]): |
93 | 108 | """Set a callback to be called when an alarm is received.""" |
94 | 109 | self.__alarm_callback_fn = callback |
95 | 110 |
|
96 | | - async def _connect(self, uuid: str): |
97 | | - """Connect to the bridge.""" |
| 111 | + async def connect(self, uuid: str): |
| 112 | + """Connect to the bridge and start reading messages.""" |
| 113 | + if self.is_connected(): |
| 114 | + _LOGGER.warning("Already connected to bridge %s", self.host) |
| 115 | + return |
| 116 | + |
| 117 | + # Get the running loop if not provided |
| 118 | + if self._loop is None: |
| 119 | + self._loop = asyncio.get_running_loop() |
| 120 | + |
98 | 121 | _LOGGER.debug("Connecting to bridge %s", self.host) |
99 | 122 | try: |
100 | 123 | self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(self.host, self.PORT), TIMEOUT) |
101 | 124 | except asyncio.TimeoutError as exc: |
102 | 125 | _LOGGER.warning("Timeout while connecting to bridge %s", self.host) |
103 | 126 | raise AioComfoConnectTimeout("Timeout while connecting to bridge") from exc |
104 | 127 |
|
105 | | - self._reference = 1 |
| 128 | + self._reference = itertools.count(1) |
106 | 129 | self._local_uuid = uuid |
107 | 130 | self._event_bus = EventBus() |
108 | 131 |
|
109 | | - async def _read_messages(): |
110 | | - while True: |
111 | | - try: |
112 | | - # Keep processing messages until we are disconnected or shutting down |
113 | | - await self._process_message() |
| 132 | + # Start background task to read messages |
| 133 | + self._read_task = self._loop.create_task(self._read_messages()) |
| 134 | + _LOGGER.debug("Connected to bridge %s", self.host) |
114 | 135 |
|
115 | | - except asyncio.exceptions.CancelledError: |
116 | | - # We are shutting down. Return to stop the background task |
117 | | - return False |
| 136 | + async def _read_messages(self): |
| 137 | + """Read messages from the bridge until disconnected or cancelled.""" |
| 138 | + try: |
| 139 | + while True: |
| 140 | + await self._process_message() |
| 141 | + except asyncio.CancelledError: |
| 142 | + _LOGGER.debug("Message reading cancelled") |
| 143 | + raise |
| 144 | + except AioComfoConnectNotConnected as exc: |
| 145 | + _LOGGER.info("Disconnected from bridge") |
| 146 | + self._notify_pending_futures(exc) |
| 147 | + raise |
| 148 | + except Exception as exc: |
| 149 | + _LOGGER.error("Unexpected error reading messages: %s", exc, exc_info=True) |
| 150 | + self._notify_pending_futures(AioComfoConnectNotConnected("Unexpected error during read")) |
| 151 | + raise |
| 152 | + |
| 153 | + def _notify_pending_futures(self, exc: Exception): |
| 154 | + """Fail all pending listeners so callers do not hang.""" |
| 155 | + if self._event_bus is None: |
| 156 | + return |
| 157 | + self._event_bus.fail_all(exc) |
| 158 | + |
| 159 | + async def disconnect(self): |
| 160 | + """Disconnect from the bridge.""" |
| 161 | + if not self.is_connected(): |
| 162 | + return |
118 | 163 |
|
119 | | - except AioComfoConnectNotConnected as exc: |
120 | | - # We have been disconnected |
121 | | - raise AioComfoConnectNotConnected("We have been disconnected") from exc |
| 164 | + _LOGGER.debug("Disconnecting from bridge %s", self.host) |
122 | 165 |
|
123 | | - read_task = self._loop.create_task(_read_messages()) |
124 | | - _LOGGER.debug("Connected to bridge %s", self.host) |
| 166 | + # Cancel the read task |
| 167 | + if self._read_task and not self._read_task.done(): |
| 168 | + self._read_task.cancel() |
| 169 | + try: |
| 170 | + await self._read_task |
| 171 | + except asyncio.CancelledError: |
| 172 | + pass |
125 | 173 |
|
126 | | - return read_task |
| 174 | + self._notify_pending_futures(AioComfoConnectNotConnected("Disconnected")) |
127 | 175 |
|
128 | | - async def _disconnect(self): |
129 | | - """Disconnect from the bridge.""" |
| 176 | + # Close the connection |
130 | 177 | if self._writer: |
131 | 178 | self._writer.close() |
132 | 179 | await self._writer.wait_closed() |
133 | 180 |
|
| 181 | + # Clear state |
| 182 | + self._reader = None |
| 183 | + self._writer = None |
| 184 | + self._read_task = None |
| 185 | + self._event_bus = None |
| 186 | + self._reference = None |
| 187 | + |
134 | 188 | def is_connected(self) -> bool: |
135 | 189 | """Returns True if the bridge is connected.""" |
136 | 190 | return self._writer is not None and not self._writer.is_closing() |
137 | 191 |
|
138 | | - async def _send(self, request, request_type, params: dict = None, reply: bool = True) -> Message: |
139 | | - """Sends a command and wait for a response if the request is known to return a result.""" |
140 | | - # Check if we are actually connected |
| 192 | + async def _send(self, request, request_type, params: dict = None, reply: bool = True, timeout: float = None) -> Message: |
| 193 | + """Sends a command and wait for a response if the request is known to return a result. |
| 194 | +
|
| 195 | + Supports concurrent requests through atomic reference allocation and lock-free sending. |
| 196 | + Multiple requests can be in-flight simultaneously, improving throughput. |
| 197 | + """ |
141 | 198 | if not self.is_connected(): |
142 | | - raise AioComfoConnectNotConnected |
| 199 | + raise AioComfoConnectNotConnected("Not connected to bridge") |
| 200 | + |
| 201 | + if timeout is None: |
| 202 | + timeout = TIMEOUT |
| 203 | + |
| 204 | + if self._loop is None: |
| 205 | + self._loop = asyncio.get_running_loop() |
143 | 206 |
|
144 | | - # Construct the message |
| 207 | + if not self.is_connected() or self._writer is None or self._reference is None: |
| 208 | + raise AioComfoConnectNotConnected("Not connected to bridge") |
| 209 | + |
| 210 | + # Allocate reference atomically (thread-safe) |
| 211 | + reference = next(self._reference) |
| 212 | + |
| 213 | + # Build command and message |
145 | 214 | cmd = zehnder_pb2.GatewayOperation() # pylint: disable=no-member |
146 | 215 | cmd.type = request_type |
147 | | - cmd.reference = self._reference |
| 216 | + cmd.reference = reference |
148 | 217 |
|
149 | 218 | msg = request() |
150 | 219 | if params is not None: |
151 | | - for param in params: |
152 | | - if params[param] is not None: |
153 | | - setattr(msg, param, params[param]) |
| 220 | + for param, value in params.items(): |
| 221 | + if value is not None: |
| 222 | + setattr(msg, param, value) |
154 | 223 |
|
155 | 224 | message = Message(cmd, msg, self._local_uuid, self.uuid) |
156 | 225 |
|
157 | | - # Create the future that will contain the response |
158 | | - fut = asyncio.Future() |
| 226 | + # Create and register future BEFORE sending to avoid race condition |
| 227 | + # where response arrives before listener is registered |
| 228 | + fut = self._loop.create_future() |
159 | 229 | if reply: |
160 | | - self._event_bus.add_listener(self._reference, fut) |
| 230 | + if self._event_bus is None: |
| 231 | + raise RuntimeError("Event bus is not initialized") |
| 232 | + self._event_bus.add_listener(reference, fut) |
161 | 233 | else: |
162 | 234 | fut.set_result(None) |
163 | 235 |
|
164 | | - # Send the message |
| 236 | + # Send message (no lock needed - TCP writes are serialized by the OS) |
165 | 237 | _LOGGER.debug("TX %s", message) |
166 | | - self._writer.write(message.encode()) |
167 | | - await self._writer.drain() |
168 | | - |
169 | | - # Increase message reference for next message |
170 | | - self._reference += 1 |
171 | | - |
172 | 238 | try: |
173 | | - return await asyncio.wait_for(fut, TIMEOUT) |
| 239 | + self._writer.write(message.encode()) |
| 240 | + await self._writer.drain() |
| 241 | + except (ConnectionError, OSError) as exc: |
| 242 | + send_exc = AioComfoConnectNotConnected("Connection lost while sending") |
| 243 | + _LOGGER.warning("Failed to send message: %s", exc) |
| 244 | + # Clean up the registered listener on send failure |
| 245 | + if reply and self._event_bus is not None: |
| 246 | + self._event_bus.emit(reference, send_exc) |
| 247 | + elif not fut.done(): |
| 248 | + fut.set_exception(send_exc) |
| 249 | + raise send_exc from exc |
| 250 | + |
| 251 | + # Wait for response |
| 252 | + try: |
| 253 | + return await asyncio.wait_for(fut, timeout) |
174 | 254 | except asyncio.TimeoutError as exc: |
175 | 255 | _LOGGER.warning("Timeout while waiting for response from bridge") |
176 | | - await self._disconnect() |
177 | 256 | raise AioComfoConnectTimeout("Timeout while waiting for response from bridge") from exc |
178 | 257 |
|
179 | 258 | async def _read(self) -> Message: |
@@ -238,21 +317,29 @@ async def _process_message(self): |
238 | 317 |
|
239 | 318 | elif message.cmd.type == zehnder_pb2.GatewayOperation.CloseSessionRequestType: |
240 | 319 | _LOGGER.info("The Bridge has asked us to close the connection.") |
| 320 | + raise AioComfoConnectNotConnected("Bridge requested connection close") |
241 | 321 |
|
242 | | - elif message.cmd.reference: |
| 322 | + elif message.cmd.reference and self._event_bus: |
243 | 323 | # Emit to the event bus |
244 | 324 | self._event_bus.emit(message.cmd.reference, message.msg) |
245 | 325 |
|
246 | 326 | else: |
247 | 327 | _LOGGER.warning("Unhandled message type %s: %s", message.cmd.type, message) |
248 | 328 |
|
249 | | - except asyncio.exceptions.IncompleteReadError as exc: |
| 329 | + except asyncio.IncompleteReadError as exc: |
250 | 330 | _LOGGER.info("The connection was closed.") |
251 | | - await self._disconnect() |
252 | | - raise AioComfoConnectNotConnected("The connection was closed.") from exc |
| 331 | + disconnect_exc = AioComfoConnectNotConnected("The connection was closed.") |
| 332 | + self._notify_pending_futures(disconnect_exc) |
| 333 | + raise disconnect_exc from exc |
| 334 | + |
| 335 | + except (ConnectionError, OSError) as exc: |
| 336 | + _LOGGER.info("Connection error: %s", exc) |
| 337 | + disconnect_exc = AioComfoConnectNotConnected("Connection error") |
| 338 | + self._notify_pending_futures(disconnect_exc) |
| 339 | + raise disconnect_exc from exc |
253 | 340 |
|
254 | 341 | except ComfoConnectError as exc: |
255 | | - if exc.message.cmd.reference: |
| 342 | + if exc.message.cmd.reference and self._event_bus: |
256 | 343 | self._event_bus.emit(exc.message.cmd.reference, exc) |
257 | 344 |
|
258 | 345 | except DecodeError as exc: |
|
0 commit comments