Skip to content

Commit c281c33

Browse files
Refactor connection handling (#66)
1 parent b5c9570 commit c281c33

11 files changed

Lines changed: 2593 additions & 847 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
*.py
21
.idea/
2+
.vscode/
33
__pycache__
44
dist/
55
script/*.pcap

aiocomfoconnect/bridge.py

Lines changed: 153 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import itertools
67
import logging
78
import struct
89
from asyncio import StreamReader, StreamWriter
9-
from typing import Awaitable
10+
from typing import Awaitable, Callable, Dict, Iterator, Optional, Set
1011

1112
from google.protobuf.message import DecodeError
1213
from google.protobuf.message import Message as ProtobufMessage
@@ -39,141 +40,219 @@ class EventBus:
3940
"""An event bus for async replies."""
4041

4142
def __init__(self):
42-
self.listeners = {}
43+
self._listeners: Dict[int, Set[asyncio.Future]] = {}
4344

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):
4551
"""Add a listener to the event bus."""
4652
_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)
5154

52-
def emit(self, event_name, event):
55+
def emit(self, event_name: int, event):
5356
"""Emit an event to the event bus."""
5457
_LOGGER.debug("Emitting for event %s", event_name)
55-
futures = self.listeners.get(event_name, [])
58+
futures = self._listeners.pop(event_name, set())
5659
for future in futures:
60+
if future.done():
61+
continue
5762
if isinstance(event, Exception):
5863
future.set_exception(event)
5964
else:
6065
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)
6276

6377

6478
class Bridge:
6579
"""ComfoConnect LAN C API."""
6680

6781
PORT = 56747
6882

69-
def __init__(self, host: str, uuid: str, loop=None):
83+
def __init__(self, host: str, uuid: str, loop: Optional[asyncio.AbstractEventLoop] = None):
7084
self.host: str = host
7185
self.uuid: str = uuid
72-
self._local_uuid: str = None
86+
self._local_uuid: Optional[str] = None
7387

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
7791

78-
self._event_bus: EventBus = None
92+
self._event_bus: Optional[EventBus] = None
7993

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
8296

83-
self._loop = loop or asyncio.get_running_loop()
97+
self._loop: Optional[asyncio.AbstractEventLoop] = loop
98+
self._read_task = None
8499

85100
def __repr__(self):
86101
return f"<Bridge {self.host}, UID={self.uuid}>"
87102

88-
def set_sensor_callback(self, callback: callable):
103+
def set_sensor_callback(self, callback: Optional[Callable[[int, int], None]]):
89104
"""Set a callback to be called when a message is received."""
90105
self.__sensor_callback_fn = callback
91106

92-
def set_alarm_callback(self, callback: callable):
107+
def set_alarm_callback(self, callback: Optional[Callable[[int, ProtobufMessage], None]]):
93108
"""Set a callback to be called when an alarm is received."""
94109
self.__alarm_callback_fn = callback
95110

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+
98121
_LOGGER.debug("Connecting to bridge %s", self.host)
99122
try:
100123
self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(self.host, self.PORT), TIMEOUT)
101124
except asyncio.TimeoutError as exc:
102125
_LOGGER.warning("Timeout while connecting to bridge %s", self.host)
103126
raise AioComfoConnectTimeout("Timeout while connecting to bridge") from exc
104127

105-
self._reference = 1
128+
self._reference = itertools.count(1)
106129
self._local_uuid = uuid
107130
self._event_bus = EventBus()
108131

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)
114135

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
118163

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)
122165

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
125173

126-
return read_task
174+
self._notify_pending_futures(AioComfoConnectNotConnected("Disconnected"))
127175

128-
async def _disconnect(self):
129-
"""Disconnect from the bridge."""
176+
# Close the connection
130177
if self._writer:
131178
self._writer.close()
132179
await self._writer.wait_closed()
133180

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+
134188
def is_connected(self) -> bool:
135189
"""Returns True if the bridge is connected."""
136190
return self._writer is not None and not self._writer.is_closing()
137191

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+
"""
141198
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()
143206

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
145214
cmd = zehnder_pb2.GatewayOperation() # pylint: disable=no-member
146215
cmd.type = request_type
147-
cmd.reference = self._reference
216+
cmd.reference = reference
148217

149218
msg = request()
150219
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)
154223

155224
message = Message(cmd, msg, self._local_uuid, self.uuid)
156225

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()
159229
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)
161233
else:
162234
fut.set_result(None)
163235

164-
# Send the message
236+
# Send message (no lock needed - TCP writes are serialized by the OS)
165237
_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-
172238
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)
174254
except asyncio.TimeoutError as exc:
175255
_LOGGER.warning("Timeout while waiting for response from bridge")
176-
await self._disconnect()
177256
raise AioComfoConnectTimeout("Timeout while waiting for response from bridge") from exc
178257

179258
async def _read(self) -> Message:
@@ -238,21 +317,29 @@ async def _process_message(self):
238317

239318
elif message.cmd.type == zehnder_pb2.GatewayOperation.CloseSessionRequestType:
240319
_LOGGER.info("The Bridge has asked us to close the connection.")
320+
raise AioComfoConnectNotConnected("Bridge requested connection close")
241321

242-
elif message.cmd.reference:
322+
elif message.cmd.reference and self._event_bus:
243323
# Emit to the event bus
244324
self._event_bus.emit(message.cmd.reference, message.msg)
245325

246326
else:
247327
_LOGGER.warning("Unhandled message type %s: %s", message.cmd.type, message)
248328

249-
except asyncio.exceptions.IncompleteReadError as exc:
329+
except asyncio.IncompleteReadError as exc:
250330
_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
253340

254341
except ComfoConnectError as exc:
255-
if exc.message.cmd.reference:
342+
if exc.message.cmd.reference and self._event_bus:
256343
self._event_bus.emit(exc.message.cmd.reference, exc)
257344

258345
except DecodeError as exc:

0 commit comments

Comments
 (0)