Skip to content

Commit 48a5a83

Browse files
Merge branch 'reconnect'
2 parents 6d7145a + 4c35053 commit 48a5a83

5 files changed

Lines changed: 902 additions & 828 deletions

File tree

aiocomfoconnect/__main__.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,10 @@ def sensor_callback(sensor, value):
258258
print("Sending keepalive...")
259259
# Use cmd_time_request as a keepalive since cmd_keepalive doesn't send back a reply we can wait for
260260
await comfoconnect.cmd_time_request()
261-
262-
except (AioComfoConnectNotConnected, AioComfoConnectTimeout):
263-
# Reconnect when connection has been dropped
264-
try:
265-
await comfoconnect.connect(uuid)
266-
except AioComfoConnectTimeout:
267-
_LOGGER.warning("Connection timed out. Retrying later...")
261+
except AioComfoConnectNotConnected:
262+
print("Got AioComfoConnectNotConnected")
263+
except AioComfoConnectTimeout:
264+
print("Got AioComfoConnectTimeout")
268265

269266
except KeyboardInterrupt:
270267
pass
@@ -310,7 +307,17 @@ def sensor_callback(sensor_, value):
310307
if follow:
311308
try:
312309
while True:
313-
await asyncio.sleep(1)
310+
# Wait for updates and send a keepalive every 30 seconds
311+
await asyncio.sleep(30)
312+
313+
try:
314+
print("Sending keepalive...")
315+
# Use cmd_time_request as a keepalive since cmd_keepalive doesn't send back a reply we can wait for
316+
await comfoconnect.cmd_time_request()
317+
except AioComfoConnectNotConnected:
318+
print("Got AioComfoConnectNotConnected")
319+
except AioComfoConnectTimeout:
320+
print("Got AioComfoConnectTimeout")
314321

315322
except KeyboardInterrupt:
316323
pass

aiocomfoconnect/bridge.py

Lines changed: 59 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import asyncio
55
import logging
66
import struct
7-
from asyncio import IncompleteReadError, StreamReader, StreamWriter
7+
from asyncio import StreamReader, StreamWriter
88
from typing import Awaitable
99

1010
from google.protobuf.message import DecodeError
@@ -71,7 +71,6 @@ def __init__(self, host: str, uuid: str, loop=None):
7171
self._reference = None
7272

7373
self._event_bus: EventBus = None
74-
self._read_task: asyncio.Task = None
7574

7675
self.__sensor_callback_fn: callable = None
7776
self.__alarm_callback_fn: callable = None
@@ -89,43 +88,43 @@ def set_alarm_callback(self, callback: callable):
8988
"""Set a callback to be called when an alarm is received."""
9089
self.__alarm_callback_fn = callback
9190

92-
async def connect(self, uuid: str):
91+
async def _connect(self, uuid: str):
9392
"""Connect to the bridge."""
94-
await self.disconnect()
95-
9693
_LOGGER.debug("Connecting to bridge %s", self.host)
9794
try:
9895
self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(self.host, self.PORT), TIMEOUT)
9996
except asyncio.TimeoutError as exc:
100-
raise AioComfoConnectTimeout() from exc
97+
_LOGGER.warning("Timeout while connecting to bridge %s", self.host)
98+
raise AioComfoConnectTimeout from exc
10199

102100
self._reference = 1
103101
self._local_uuid = uuid
104102
self._event_bus = EventBus()
105103

106-
# We are connected, start the background task
107-
self._read_task = self._loop.create_task(self._read_messages())
104+
async def _read_messages():
105+
while True:
106+
try:
107+
# Keep processing messages until we are disconnected or shutting down
108+
await self._process_message()
108109

109-
_LOGGER.debug("Connected to bridge %s", self.host)
110+
except asyncio.exceptions.CancelledError:
111+
# We are shutting down. Return to stop the background task
112+
return False
110113

111-
async def disconnect(self):
112-
"""Disconnect from the bridge."""
113-
_LOGGER.debug("Disconnecting from bridge %s", self.host)
114+
except AioComfoConnectNotConnected:
115+
# We have been disconnected
116+
raise
114117

115-
if self._read_task:
116-
# Cancel the background task
117-
self._read_task.cancel()
118+
read_task = self._loop.create_task(_read_messages())
119+
_LOGGER.debug("Connected to bridge %s", self.host)
118120

119-
# Wait for background task to finish
120-
try:
121-
await self._read_task
122-
except asyncio.CancelledError:
123-
pass
121+
return read_task
124122

123+
async def _disconnect(self):
124+
"""Disconnect from the bridge."""
125125
if self._writer:
126126
self._writer.close()
127-
128-
_LOGGER.debug("Disconnected from bridge %s", self.host)
127+
await self._writer.wait_closed()
129128

130129
def is_connected(self) -> bool:
131130
"""Returns True if the bridge is connected."""
@@ -135,7 +134,7 @@ async def _send(self, request, request_type, params: dict = None, reply: bool =
135134
"""Sends a command and wait for a response if the request is known to return a result."""
136135
# Check if we are actually connected
137136
if not self.is_connected():
138-
raise AioComfoConnectNotConnected()
137+
raise AioComfoConnectNotConnected
139138

140139
# Construct the message
141140
cmd = zehnder_pb2.GatewayOperation() # pylint: disable=no-member
@@ -160,6 +159,7 @@ async def _send(self, request, request_type, params: dict = None, reply: bool =
160159
# Send the message
161160
_LOGGER.debug("TX %s", message)
162161
self._writer.write(message.encode())
162+
await self._writer.drain()
163163

164164
# Increase message reference for next message
165165
self._reference += 1
@@ -168,6 +168,7 @@ async def _send(self, request, request_type, params: dict = None, reply: bool =
168168
return await asyncio.wait_for(fut, TIMEOUT)
169169
except asyncio.TimeoutError as exc:
170170
_LOGGER.warning("Timeout while waiting for response from bridge")
171+
await self._disconnect()
171172
raise AioComfoConnectTimeout from exc
172173

173174
async def _read(self) -> Message:
@@ -206,55 +207,51 @@ async def _read(self) -> Message:
206207

207208
return message
208209

209-
async def _read_messages(self):
210-
"""Receive a message from the bridge."""
211-
while self._read_task.cancelled() is False:
212-
try:
213-
message = await self._read()
214-
215-
# pylint: disable=no-member
216-
if message.cmd.type == zehnder_pb2.GatewayOperation.CnRpdoNotificationType:
217-
if self.__sensor_callback_fn:
218-
self.__sensor_callback_fn(message.msg.pdid, int.from_bytes(message.msg.data, byteorder="little", signed=True))
219-
else:
220-
_LOGGER.info("Unhandled CnRpdoNotificationType since no callback is registered.")
210+
async def _process_message(self):
211+
"""Process a message from the bridge."""
212+
try:
213+
message = await self._read()
221214

222-
elif message.cmd.type == zehnder_pb2.GatewayOperation.GatewayNotificationType:
223-
_LOGGER.debug("Unhandled GatewayNotificationType")
215+
# pylint: disable=no-member
216+
if message.cmd.type == zehnder_pb2.GatewayOperation.CnRpdoNotificationType:
217+
if self.__sensor_callback_fn:
218+
self.__sensor_callback_fn(message.msg.pdid, int.from_bytes(message.msg.data, byteorder="little", signed=True))
219+
else:
220+
_LOGGER.info("Unhandled CnRpdoNotificationType since no callback is registered.")
224221

225-
elif message.cmd.type == zehnder_pb2.GatewayOperation.CnNodeNotificationType:
226-
_LOGGER.debug("Unhandled CnNodeNotificationType")
222+
elif message.cmd.type == zehnder_pb2.GatewayOperation.GatewayNotificationType:
223+
_LOGGER.debug("Unhandled GatewayNotificationType")
227224

228-
elif message.cmd.type == zehnder_pb2.GatewayOperation.CnAlarmNotificationType:
229-
if self.__alarm_callback_fn:
230-
self.__alarm_callback_fn(message.msg.nodeId, message.msg)
231-
else:
232-
_LOGGER.info("Unhandled CnAlarmNotificationType since no callback is registered.")
225+
elif message.cmd.type == zehnder_pb2.GatewayOperation.CnNodeNotificationType:
226+
_LOGGER.debug("Unhandled CnNodeNotificationType")
233227

234-
elif message.cmd.type == zehnder_pb2.GatewayOperation.CloseSessionRequestType:
235-
_LOGGER.info("The Bridge has asked us to close the connection.")
236-
return # Stop the background task
228+
elif message.cmd.type == zehnder_pb2.GatewayOperation.CnAlarmNotificationType:
229+
if self.__alarm_callback_fn:
230+
self.__alarm_callback_fn(message.msg.nodeId, message.msg)
231+
else:
232+
_LOGGER.info("Unhandled CnAlarmNotificationType since no callback is registered.")
237233

238-
elif message.cmd.reference:
239-
# Emit to the event bus
240-
self._event_bus.emit(message.cmd.reference, message.msg)
234+
elif message.cmd.type == zehnder_pb2.GatewayOperation.CloseSessionRequestType:
235+
_LOGGER.info("The Bridge has asked us to close the connection.")
241236

242-
else:
243-
_LOGGER.warning("Unhandled message type %s: %s", message.cmd.type, message)
237+
elif message.cmd.reference:
238+
# Emit to the event bus
239+
self._event_bus.emit(message.cmd.reference, message.msg)
244240

245-
except asyncio.exceptions.CancelledError:
246-
return # Stop the background task
241+
else:
242+
_LOGGER.warning("Unhandled message type %s: %s", message.cmd.type, message)
247243

248-
except IncompleteReadError:
249-
_LOGGER.info("The connection was closed.")
250-
return # Stop the background task
244+
except asyncio.exceptions.IncompleteReadError:
245+
_LOGGER.info("The connection was closed.")
246+
await self._disconnect()
247+
raise AioComfoConnectNotConnected
251248

252-
except ComfoConnectError as exc:
253-
if exc.message.cmd.reference:
254-
self._event_bus.emit(exc.message.cmd.reference, exc)
249+
except ComfoConnectError as exc:
250+
if exc.message.cmd.reference:
251+
self._event_bus.emit(exc.message.cmd.reference, exc)
255252

256-
except DecodeError as exc:
257-
_LOGGER.error("Failed to decode message: %s", exc)
253+
except DecodeError as exc:
254+
_LOGGER.error("Failed to decode message: %s", exc)
258255

259256
def cmd_start_session(self, take_over: bool = False) -> Awaitable[Message]:
260257
"""Starts the session on the device by logging in and optionally disconnecting an already existing session."""

aiocomfoconnect/comfoconnect.py

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
""" ComfoConnect Bridge API abstraction """
22
from __future__ import annotations
33

4+
import asyncio
45
import logging
6+
from asyncio import Future
57
from typing import Callable, Dict, List, Literal
68

79
from aiocomfoconnect import Bridge
@@ -15,17 +17,21 @@
1517
SUBUNIT_06,
1618
SUBUNIT_07,
1719
SUBUNIT_08,
18-
PdoType,
1920
UNIT_ERROR,
2021
UNIT_SCHEDULE,
2122
UNIT_TEMPHUMCONTROL,
2223
UNIT_VENTILATIONCONFIG,
24+
PdoType,
2325
VentilationBalance,
2426
VentilationMode,
2527
VentilationSetting,
2628
VentilationSpeed,
2729
VentilationTemperatureProfile,
2830
)
31+
from aiocomfoconnect.exceptions import (
32+
AioComfoConnectNotConnected,
33+
AioComfoConnectTimeout,
34+
)
2935
from aiocomfoconnect.properties import Property
3036
from aiocomfoconnect.sensors import Sensor
3137
from aiocomfoconnect.util import bytearray_to_bits, bytestring, encode_pdo_value
@@ -50,31 +56,70 @@ def __init__(self, host: str, uuid: str, loop=None, sensor_callback=None, alarm_
5056
self._sensors_values: Dict[int, any] = {}
5157
self._sensor_hold = None
5258

59+
self._tasks = set()
60+
5361
def _unhold_sensors(self):
5462
"""Unhold the sensors."""
5563
_LOGGER.debug("Unholding sensors")
5664
self._sensor_hold = None
5765

58-
# Emit the current cached values of the sensors, by now, they will have received a correct update.
66+
# Emit the current cached values of the sensors, by now, they should have received a correct update.
5967
for sensor_id, _ in self._sensors.items():
6068
if self._sensors_values[sensor_id] is not None:
6169
self._sensor_callback(sensor_id, self._sensors_values[sensor_id])
6270

63-
async def connect(self, uuid: str, start_session=True):
71+
async def connect(self, uuid: str):
6472
"""Connect to the bridge."""
65-
await super().connect(uuid)
73+
connected: Future = Future()
74+
75+
async def _reconnect_loop():
76+
while True:
77+
try:
78+
# Connect to the bridge
79+
read_task = await self._connect(uuid)
80+
81+
# Start session
82+
await self.cmd_start_session(True)
83+
84+
# Wait for a specified amount of seconds to buffer sensor values.
85+
# This is to work around a bug where the bridge sends invalid sensor values when connecting.
86+
if self.sensor_delay:
87+
_LOGGER.debug("Holding sensors for %s second(s)", self.sensor_delay)
88+
self._sensors_values = {}
89+
self._sensor_hold = self._loop.call_later(self.sensor_delay, self._unhold_sensors)
90+
91+
# Register the sensors again (in case we lost the connection)
92+
for sensor in self._sensors.values():
93+
await self.cmd_rpdo_request(sensor.id, sensor.type)
94+
95+
if not connected.done():
96+
connected.set_result(True)
97+
98+
# Wait for the read task to finish or throw an exception
99+
await read_task
100+
101+
if read_task.result() is False:
102+
# We are shutting down.
103+
return
104+
105+
except AioComfoConnectTimeout:
106+
# Reconnect after 5 seconds when we could not connect
107+
_LOGGER.info("Could not reconnect. Retrying after 5 seconds.")
108+
await asyncio.sleep(5)
109+
110+
except AioComfoConnectNotConnected:
111+
# Reconnect when connection has been dropped
112+
_LOGGER.info("We got disconnected. Reconnecting.")
113+
pass
66114

67-
if start_session:
68-
await self.cmd_start_session(True)
115+
reconnect_task = self._loop.create_task(_reconnect_loop())
116+
self._tasks.add(reconnect_task)
117+
reconnect_task.add_done_callback(self._tasks.discard)
69118

70-
if self.sensor_delay:
71-
_LOGGER.debug("Holding sensors for %s second(s)", self.sensor_delay)
72-
self._sensor_hold = self._loop.call_later(self.sensor_delay, self._unhold_sensors)
119+
await connected
73120

74-
# Register the sensors again (in case we lost the connection)
75-
self._sensors_values = {}
76-
for sensor in self._sensors.values():
77-
await self.cmd_rpdo_request(sensor.id, sensor.type)
121+
async def disconnect(self):
122+
await self._disconnect()
78123

79124
async def register_sensor(self, sensor: Sensor):
80125
"""Register a sensor on the bridge."""

aiocomfoconnect/properties.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,7 @@
33

44
from dataclasses import dataclass
55

6-
from .const import (
7-
PdoType,
8-
UNIT_NODE,
9-
UNIT_NODECONFIGURATION,
10-
UNIT_TEMPHUMCONTROL,
11-
)
6+
from .const import UNIT_NODE, UNIT_NODECONFIGURATION, UNIT_TEMPHUMCONTROL, PdoType
127

138

149
@dataclass

0 commit comments

Comments
 (0)