pypck 0.7.24__py3-none-any.whl → 0.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pypck/connection.py +328 -389
- pypck/inputs.py +40 -0
- pypck/lcn_defs.py +26 -9
- pypck/module.py +16 -15
- pypck/pck_commands.py +17 -1
- pypck/request_handlers.py +26 -26
- pypck/timeout_retry.py +8 -18
- {pypck-0.7.24.dist-info → pypck-0.8.2.dist-info}/METADATA +2 -2
- pypck-0.8.2.dist-info/RECORD +15 -0
- {pypck-0.7.24.dist-info → pypck-0.8.2.dist-info}/WHEEL +1 -1
- pypck-0.7.24.dist-info/RECORD +0 -15
- {pypck-0.7.24.dist-info → pypck-0.8.2.dist-info}/LICENSE +0 -0
- {pypck-0.7.24.dist-info → pypck-0.8.2.dist-info}/top_level.txt +0 -0
pypck/connection.py
CHANGED
|
@@ -5,22 +5,19 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
7
|
import time
|
|
8
|
-
from collections import
|
|
9
|
-
from collections.abc import Awaitable, Callable, Iterable
|
|
8
|
+
from collections.abc import Callable, Iterable
|
|
10
9
|
from types import TracebackType
|
|
11
10
|
from typing import Any
|
|
12
11
|
|
|
13
12
|
from pypck import inputs, lcn_defs
|
|
14
13
|
from pypck.helpers import TaskRegistry
|
|
15
14
|
from pypck.lcn_addr import LcnAddr
|
|
15
|
+
from pypck.lcn_defs import LcnEvent
|
|
16
16
|
from pypck.module import AbstractConnection, GroupConnection, ModuleConnection
|
|
17
17
|
from pypck.pck_commands import PckGenerator
|
|
18
18
|
|
|
19
19
|
_LOGGER = logging.getLogger(__name__)
|
|
20
20
|
|
|
21
|
-
READ_TIMEOUT = -1
|
|
22
|
-
SOCKET_CLOSED = -2
|
|
23
|
-
|
|
24
21
|
|
|
25
22
|
class PchkLicenseError(Exception):
|
|
26
23
|
"""Exception which is raised if a license error occurred."""
|
|
@@ -29,7 +26,7 @@ class PchkLicenseError(Exception):
|
|
|
29
26
|
"""Initialize instance."""
|
|
30
27
|
if message is None:
|
|
31
28
|
message = (
|
|
32
|
-
"Maximum number of connections was reached. An "
|
|
29
|
+
"License Error: Maximum number of connections was reached. An "
|
|
33
30
|
"additional license key is required."
|
|
34
31
|
)
|
|
35
32
|
super().__init__(message)
|
|
@@ -41,182 +38,47 @@ class PchkAuthenticationError(Exception):
|
|
|
41
38
|
def __init__(self, message: str | None = None):
|
|
42
39
|
"""Initialize instance."""
|
|
43
40
|
if message is None:
|
|
44
|
-
message = "Authentication failed
|
|
41
|
+
message = "Authentication failed"
|
|
45
42
|
super().__init__(message)
|
|
46
43
|
|
|
47
44
|
|
|
48
|
-
class
|
|
49
|
-
"""Exception which is raised if
|
|
45
|
+
class PchkConnectionRefusedError(Exception):
|
|
46
|
+
"""Exception which is raised if connection was refused."""
|
|
50
47
|
|
|
51
48
|
def __init__(self, message: str | None = None):
|
|
52
49
|
"""Initialize instance."""
|
|
53
50
|
if message is None:
|
|
54
|
-
message = "
|
|
51
|
+
message = "Connection refused"
|
|
55
52
|
super().__init__(message)
|
|
56
53
|
|
|
57
54
|
|
|
58
|
-
class
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
:param str host: Server IP address formatted as
|
|
62
|
-
xxx.xxx.xxx.xxx
|
|
63
|
-
:param int port: Server port
|
|
64
|
-
|
|
65
|
-
:Note:
|
|
66
|
-
|
|
67
|
-
:class:`PchkConnection` does only open a port to the
|
|
68
|
-
PCHK server and allows to send and receive plain text. Use
|
|
69
|
-
:func:`~PchkConnection.send_command` and
|
|
70
|
-
:func:`~PchkConnection.process_input` callback to send and receive
|
|
71
|
-
text messages.
|
|
72
|
-
|
|
73
|
-
For login logic or communication with modules use
|
|
74
|
-
:class:`~PchkConnectionManager`.
|
|
75
|
-
"""
|
|
76
|
-
|
|
77
|
-
def __init__(self, host: str, port: int, connection_id: str = "PCHK"):
|
|
78
|
-
"""Construct PchkConnection."""
|
|
79
|
-
self.task_registry = TaskRegistry()
|
|
80
|
-
self.host = host
|
|
81
|
-
self.port = port
|
|
82
|
-
self.connection_id = connection_id
|
|
83
|
-
self.reader: asyncio.StreamReader | None = None
|
|
84
|
-
self.writer: asyncio.StreamWriter | None = None
|
|
85
|
-
self.buffer: deque[bytes] = deque()
|
|
86
|
-
self.idle_time = 0.05
|
|
87
|
-
self.last_bus_activity = time.time()
|
|
88
|
-
self.event_handler: Callable[[str], Awaitable[None]] = (
|
|
89
|
-
self.default_event_handler
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
async def async_connect(self) -> None:
|
|
93
|
-
"""Connect to a PCHK server (no authentication or license error check)."""
|
|
94
|
-
self.reader, self.writer = await asyncio.open_connection(self.host, self.port)
|
|
95
|
-
address = self.writer.get_extra_info("peername")
|
|
96
|
-
_LOGGER.debug("%s server connected at %s:%d", self.connection_id, *address)
|
|
97
|
-
|
|
98
|
-
# main write loop
|
|
99
|
-
self.task_registry.create_task(self.write_data_loop())
|
|
100
|
-
|
|
101
|
-
# main read loop
|
|
102
|
-
self.task_registry.create_task(self.read_data_loop())
|
|
103
|
-
|
|
104
|
-
def connect(self) -> None:
|
|
105
|
-
"""Create a task to connect to a PCHK server concurrently."""
|
|
106
|
-
self.task_registry.create_task(self.async_connect())
|
|
107
|
-
|
|
108
|
-
async def read_data_loop(self) -> None:
|
|
109
|
-
"""Is called when some data is received."""
|
|
110
|
-
assert self.reader is not None
|
|
111
|
-
assert self.writer is not None
|
|
112
|
-
while not self.writer.is_closing():
|
|
113
|
-
try:
|
|
114
|
-
data = await self.reader.readuntil(PckGenerator.TERMINATION.encode())
|
|
115
|
-
self.last_bus_activity = time.time()
|
|
116
|
-
except asyncio.IncompleteReadError:
|
|
117
|
-
_LOGGER.debug("Connection to %s lost", self.connection_id)
|
|
118
|
-
await self.event_handler("connection-lost")
|
|
119
|
-
await self.async_close()
|
|
120
|
-
break
|
|
121
|
-
except asyncio.CancelledError:
|
|
122
|
-
break
|
|
123
|
-
|
|
124
|
-
try:
|
|
125
|
-
message = data.decode().split(PckGenerator.TERMINATION)[0]
|
|
126
|
-
except UnicodeDecodeError as err:
|
|
127
|
-
_LOGGER.warning(
|
|
128
|
-
"PCK decoding error: %s - skipping received PCK message", err
|
|
129
|
-
)
|
|
130
|
-
continue
|
|
131
|
-
await self.process_message(message)
|
|
132
|
-
|
|
133
|
-
async def write_data_loop(self) -> None:
|
|
134
|
-
"""Processes queue and writes data."""
|
|
135
|
-
assert self.writer is not None
|
|
136
|
-
while not self.writer.is_closing():
|
|
137
|
-
await asyncio.sleep(self.idle_time)
|
|
138
|
-
if len(self.buffer) == 0:
|
|
139
|
-
continue
|
|
140
|
-
if time.time() - self.last_bus_activity < self.idle_time:
|
|
141
|
-
continue
|
|
142
|
-
data = self.buffer.popleft()
|
|
143
|
-
self.last_bus_activity = time.time()
|
|
144
|
-
|
|
145
|
-
_LOGGER.debug(
|
|
146
|
-
"to %s: %s",
|
|
147
|
-
self.connection_id,
|
|
148
|
-
data.decode().rstrip(PckGenerator.TERMINATION),
|
|
149
|
-
)
|
|
150
|
-
self.writer.write(data)
|
|
151
|
-
await self.writer.drain()
|
|
152
|
-
|
|
153
|
-
async def send_command(self, pck: bytes | str, **kwargs: Any) -> bool:
|
|
154
|
-
"""Send a PCK command to the PCHK server.
|
|
155
|
-
|
|
156
|
-
:param str pck: PCK command
|
|
157
|
-
"""
|
|
158
|
-
assert self.writer is not None
|
|
159
|
-
if not self.writer.is_closing():
|
|
160
|
-
if isinstance(pck, str):
|
|
161
|
-
data = (pck + PckGenerator.TERMINATION).encode()
|
|
162
|
-
else:
|
|
163
|
-
data = pck + PckGenerator.TERMINATION.encode()
|
|
164
|
-
self.buffer.append(data)
|
|
165
|
-
return True
|
|
166
|
-
return False
|
|
167
|
-
|
|
168
|
-
async def process_message(self, message: str) -> None:
|
|
169
|
-
"""Is called when a new text message is received from the PCHK server.
|
|
170
|
-
|
|
171
|
-
This class should be reimplemented in any subclass which evaluates
|
|
172
|
-
received messages.
|
|
173
|
-
|
|
174
|
-
:param str input: Input text message
|
|
175
|
-
"""
|
|
176
|
-
_LOGGER.debug("from %s: %s", self.connection_id, message)
|
|
55
|
+
class PchkConnectionFailedError(Exception):
|
|
56
|
+
"""Exception which is raised if connection was refused."""
|
|
177
57
|
|
|
178
|
-
|
|
179
|
-
"""
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
await self.writer.wait_closed()
|
|
184
|
-
|
|
185
|
-
def set_event_handler(self, coro: Callable[[str], Awaitable[None]]) -> None:
|
|
186
|
-
"""Set the event handler for specific LCN events."""
|
|
187
|
-
if coro is None:
|
|
188
|
-
self.event_handler = self.default_event_handler
|
|
189
|
-
else:
|
|
190
|
-
self.event_handler = coro
|
|
58
|
+
def __init__(self, message: str | None = None):
|
|
59
|
+
"""Initialize instance."""
|
|
60
|
+
if message is None:
|
|
61
|
+
message = "Connection failed"
|
|
62
|
+
super().__init__(message)
|
|
191
63
|
|
|
192
|
-
async def default_event_handler(self, event: str) -> None:
|
|
193
|
-
"""Handle events for specific LCN events."""
|
|
194
|
-
|
|
195
|
-
async def wait_closed(self) -> None:
|
|
196
|
-
"""Wait until connection to PCHK server is closed."""
|
|
197
|
-
if self.writer is not None:
|
|
198
|
-
await self.writer.wait_closed()
|
|
199
64
|
|
|
65
|
+
class PchkLcnNotConnectedError(Exception):
|
|
66
|
+
"""Exception which is raised if there is no connection to the LCN bus."""
|
|
200
67
|
|
|
201
|
-
|
|
202
|
-
|
|
68
|
+
def __init__(self, message: str | None = None):
|
|
69
|
+
"""Initialize instance."""
|
|
70
|
+
if message is None:
|
|
71
|
+
message = "LCN not connected."
|
|
72
|
+
super().__init__(message)
|
|
203
73
|
|
|
204
|
-
Has the following tasks:
|
|
205
|
-
- Initiates login procedure.
|
|
206
|
-
- Ping PCHK.
|
|
207
|
-
- Parse incoming commands and create input objects.
|
|
208
|
-
- Calls input object's process method.
|
|
209
|
-
- Updates seg_id of ModuleConnections if segment scan finishes.
|
|
210
74
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
:param int port: Server port
|
|
214
|
-
:param str username: usernam for login.
|
|
215
|
-
:param str password: Password for login.
|
|
75
|
+
class PchkConnectionManager:
|
|
76
|
+
"""Connection to LCN-PCHK."""
|
|
216
77
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
78
|
+
last_ping: float
|
|
79
|
+
ping_timeout_handle: asyncio.TimerHandle | None
|
|
80
|
+
authentication_completed_future: asyncio.Future[bool]
|
|
81
|
+
license_error_future: asyncio.Future[bool]
|
|
220
82
|
|
|
221
83
|
def __init__(
|
|
222
84
|
self,
|
|
@@ -226,23 +88,32 @@ class PchkConnectionManager(PchkConnection):
|
|
|
226
88
|
password: str,
|
|
227
89
|
settings: dict[str, Any] | None = None,
|
|
228
90
|
connection_id: str = "PCHK",
|
|
229
|
-
):
|
|
91
|
+
) -> None:
|
|
230
92
|
"""Construct PchkConnectionManager."""
|
|
231
|
-
|
|
93
|
+
self.task_registry = TaskRegistry()
|
|
94
|
+
self.host = host
|
|
95
|
+
self.port = port
|
|
96
|
+
self.connection_id = connection_id
|
|
97
|
+
|
|
98
|
+
self.reader: asyncio.StreamReader | None = None
|
|
99
|
+
self.writer: asyncio.StreamWriter | None = None
|
|
100
|
+
self.buffer: asyncio.Queue[bytes] = asyncio.Queue()
|
|
101
|
+
self.last_bus_activity = time.time()
|
|
232
102
|
|
|
233
103
|
self.username = username
|
|
234
104
|
self.password = password
|
|
235
105
|
|
|
106
|
+
# Settings
|
|
236
107
|
if settings is None:
|
|
237
108
|
settings = {}
|
|
238
109
|
self.settings = lcn_defs.default_connection_settings
|
|
239
110
|
self.settings.update(settings)
|
|
240
111
|
|
|
241
112
|
self.idle_time = self.settings["BUS_IDLE_TIME"]
|
|
242
|
-
|
|
243
|
-
self.
|
|
113
|
+
self.ping_send_delay = self.settings["PING_SEND_DELAY"]
|
|
114
|
+
self.ping_recv_timeout = self.settings["PING_RECV_TIMEOUT"]
|
|
115
|
+
self.ping_timeout_handle = None
|
|
244
116
|
self.ping_counter = 0
|
|
245
|
-
|
|
246
117
|
self.dim_mode = self.settings["DIM_MODE"]
|
|
247
118
|
self.status_mode = lcn_defs.OutputPortStatusMode.PERCENT
|
|
248
119
|
|
|
@@ -251,8 +122,8 @@ class PchkConnectionManager(PchkConnection):
|
|
|
251
122
|
|
|
252
123
|
# Events, Futures, Locks for synchronization
|
|
253
124
|
self.segment_scan_completed_event = asyncio.Event()
|
|
254
|
-
self.authentication_completed_future
|
|
255
|
-
self.license_error_future
|
|
125
|
+
self.authentication_completed_future = asyncio.Future()
|
|
126
|
+
self.license_error_future = asyncio.Future()
|
|
256
127
|
self.module_serial_number_received = asyncio.Lock()
|
|
257
128
|
self.segment_coupler_response_received = asyncio.Lock()
|
|
258
129
|
|
|
@@ -265,6 +136,156 @@ class PchkConnectionManager(PchkConnection):
|
|
|
265
136
|
self.segment_coupler_ids: list[int] = []
|
|
266
137
|
|
|
267
138
|
self.input_callbacks: set[Callable[[inputs.Input], None]] = set()
|
|
139
|
+
self.event_callbacks: set[Callable[[LcnEvent], None]] = set()
|
|
140
|
+
self.register_for_events(self.event_callback)
|
|
141
|
+
|
|
142
|
+
# Socket read/write
|
|
143
|
+
|
|
144
|
+
async def read_data_loop(self) -> None:
|
|
145
|
+
"""Processes incoming data."""
|
|
146
|
+
assert self.reader is not None
|
|
147
|
+
assert self.writer is not None
|
|
148
|
+
_LOGGER.debug("Read data loop started")
|
|
149
|
+
try:
|
|
150
|
+
while not self.writer.is_closing():
|
|
151
|
+
try:
|
|
152
|
+
data = await self.reader.readuntil(
|
|
153
|
+
PckGenerator.TERMINATION.encode()
|
|
154
|
+
)
|
|
155
|
+
self.last_bus_activity = time.time()
|
|
156
|
+
except (
|
|
157
|
+
asyncio.IncompleteReadError,
|
|
158
|
+
TimeoutError,
|
|
159
|
+
OSError,
|
|
160
|
+
):
|
|
161
|
+
_LOGGER.debug("Connection to %s lost", self.connection_id)
|
|
162
|
+
self.fire_event(LcnEvent.CONNECTION_LOST)
|
|
163
|
+
await self.async_close()
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
message = data.decode().split(PckGenerator.TERMINATION)[0]
|
|
168
|
+
except UnicodeDecodeError as err:
|
|
169
|
+
_LOGGER.warning(
|
|
170
|
+
"PCK decoding error: %s - skipping received PCK message", err
|
|
171
|
+
)
|
|
172
|
+
continue
|
|
173
|
+
await self.process_message(message)
|
|
174
|
+
except asyncio.CancelledError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
_LOGGER.debug("Read data loop closed")
|
|
178
|
+
|
|
179
|
+
async def write_data_loop(self) -> None:
|
|
180
|
+
"""Processes queue and writes data."""
|
|
181
|
+
assert self.writer is not None
|
|
182
|
+
try:
|
|
183
|
+
_LOGGER.debug("Write data loop started")
|
|
184
|
+
while not self.writer.is_closing():
|
|
185
|
+
data = await self.buffer.get()
|
|
186
|
+
while (time.time() - self.last_bus_activity) < self.idle_time:
|
|
187
|
+
await asyncio.sleep(self.idle_time)
|
|
188
|
+
|
|
189
|
+
_LOGGER.debug(
|
|
190
|
+
"to %s: %s",
|
|
191
|
+
self.connection_id,
|
|
192
|
+
data.decode().rstrip(PckGenerator.TERMINATION),
|
|
193
|
+
)
|
|
194
|
+
self.writer.write(data)
|
|
195
|
+
await self.writer.drain()
|
|
196
|
+
self.last_bus_activity = time.time()
|
|
197
|
+
except asyncio.CancelledError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
# empty the queue
|
|
201
|
+
while not self.buffer.empty():
|
|
202
|
+
await self.buffer.get()
|
|
203
|
+
|
|
204
|
+
_LOGGER.debug("Write data loop closed")
|
|
205
|
+
|
|
206
|
+
# Open/close connection, authentication & setup.
|
|
207
|
+
|
|
208
|
+
async def async_connect(self, timeout: float = 30) -> None:
|
|
209
|
+
"""Establish a connection to PCHK at the given socket."""
|
|
210
|
+
self.authentication_completed_future = asyncio.Future()
|
|
211
|
+
self.license_error_future = asyncio.Future()
|
|
212
|
+
|
|
213
|
+
_LOGGER.debug(
|
|
214
|
+
"Starting connection attempt to %s server at %s:%d",
|
|
215
|
+
self.connection_id,
|
|
216
|
+
self.host,
|
|
217
|
+
self.port,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
done: Iterable[asyncio.Future[Any]]
|
|
221
|
+
pending: Iterable[asyncio.Future[Any]]
|
|
222
|
+
done, pending = await asyncio.wait(
|
|
223
|
+
(
|
|
224
|
+
asyncio.create_task(self.open_connection()),
|
|
225
|
+
self.license_error_future,
|
|
226
|
+
self.authentication_completed_future,
|
|
227
|
+
),
|
|
228
|
+
timeout=timeout,
|
|
229
|
+
return_when=asyncio.FIRST_EXCEPTION,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Raise any exception which occurs
|
|
233
|
+
# (ConnectionRefusedError, PchkAuthenticationError, PchkLicenseError)
|
|
234
|
+
for awaitable in done:
|
|
235
|
+
if not awaitable.cancelled():
|
|
236
|
+
if exc := awaitable.exception():
|
|
237
|
+
await self.async_close()
|
|
238
|
+
if isinstance(exc, (ConnectionRefusedError, OSError)):
|
|
239
|
+
raise PchkConnectionRefusedError()
|
|
240
|
+
else:
|
|
241
|
+
raise awaitable.exception() # type: ignore
|
|
242
|
+
|
|
243
|
+
if pending:
|
|
244
|
+
for awaitable in pending:
|
|
245
|
+
awaitable.cancel()
|
|
246
|
+
await self.async_close()
|
|
247
|
+
raise PchkConnectionFailedError()
|
|
248
|
+
|
|
249
|
+
if not self.is_lcn_connected:
|
|
250
|
+
raise PchkLcnNotConnectedError()
|
|
251
|
+
|
|
252
|
+
# start segment scan
|
|
253
|
+
await self.scan_segment_couplers(
|
|
254
|
+
self.settings["SK_NUM_TRIES"], self.settings["DEFAULT_TIMEOUT"]
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def open_connection(self) -> None:
|
|
258
|
+
"""Connect to PCHK server (no authentication or license error check)."""
|
|
259
|
+
self.reader, self.writer = await asyncio.open_connection(self.host, self.port)
|
|
260
|
+
|
|
261
|
+
address = self.writer.get_extra_info("peername")
|
|
262
|
+
_LOGGER.debug("%s server connected at %s:%d", self.connection_id, *address)
|
|
263
|
+
|
|
264
|
+
# main write loop
|
|
265
|
+
self.task_registry.create_task(self.write_data_loop())
|
|
266
|
+
|
|
267
|
+
# main read loop
|
|
268
|
+
self.task_registry.create_task(self.read_data_loop())
|
|
269
|
+
|
|
270
|
+
async def async_close(self) -> None:
|
|
271
|
+
"""Close the active connection."""
|
|
272
|
+
await self.cancel_requests()
|
|
273
|
+
if self.ping_timeout_handle is not None:
|
|
274
|
+
self.ping_timeout_handle.cancel()
|
|
275
|
+
await self.task_registry.cancel_all_tasks()
|
|
276
|
+
if self.writer:
|
|
277
|
+
self.writer.close()
|
|
278
|
+
try:
|
|
279
|
+
await self.writer.wait_closed()
|
|
280
|
+
except OSError: # occurs when TCP connection is lost
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
_LOGGER.debug("Connection to %s closed.", self.connection_id)
|
|
284
|
+
|
|
285
|
+
async def wait_closed(self) -> None:
|
|
286
|
+
"""Wait until connection to PCHK server is closed."""
|
|
287
|
+
if self.writer is not None:
|
|
288
|
+
await self.writer.wait_closed()
|
|
268
289
|
|
|
269
290
|
async def __aenter__(self) -> "PchkConnectionManager":
|
|
270
291
|
"""Context manager enter method."""
|
|
@@ -281,17 +302,6 @@ class PchkConnectionManager(PchkConnection):
|
|
|
281
302
|
await self.async_close()
|
|
282
303
|
return None
|
|
283
304
|
|
|
284
|
-
async def send_command(
|
|
285
|
-
self, pck: bytes | str, to_host: bool = False, **kwargs: Any
|
|
286
|
-
) -> bool:
|
|
287
|
-
"""Send a PCK command to the PCHK server.
|
|
288
|
-
|
|
289
|
-
:param str pck: PCK command
|
|
290
|
-
"""
|
|
291
|
-
if not self.is_lcn_connected and not to_host:
|
|
292
|
-
return False
|
|
293
|
-
return await super().send_command(pck)
|
|
294
|
-
|
|
295
305
|
async def on_auth(self, success: bool) -> None:
|
|
296
306
|
"""Is called after successful authentication."""
|
|
297
307
|
if success:
|
|
@@ -318,71 +328,30 @@ class PchkConnectionManager(PchkConnection):
|
|
|
318
328
|
self.task_registry.create_task(self.ping())
|
|
319
329
|
|
|
320
330
|
async def lcn_connection_status_changed(self, is_lcn_connected: bool) -> None:
|
|
321
|
-
"""Set the current connection state to the LCN bus.
|
|
322
|
-
|
|
323
|
-
:param bool is_lcn_connected: Current connection status
|
|
324
|
-
"""
|
|
331
|
+
"""Set the current connection state to the LCN bus."""
|
|
325
332
|
self.is_lcn_connected = is_lcn_connected
|
|
326
|
-
self.
|
|
327
|
-
self.event_handler("lcn-connection-status-changed")
|
|
328
|
-
)
|
|
333
|
+
self.fire_event(LcnEvent.BUS_CONNECTION_STATUS_CHANGED)
|
|
329
334
|
if is_lcn_connected:
|
|
330
335
|
_LOGGER.debug("%s: LCN is connected.", self.connection_id)
|
|
331
|
-
self.
|
|
336
|
+
self.fire_event(LcnEvent.BUS_CONNECTED)
|
|
332
337
|
else:
|
|
333
338
|
_LOGGER.debug("%s: LCN is not connected.", self.connection_id)
|
|
334
|
-
self.
|
|
335
|
-
|
|
336
|
-
async def async_connect(self, timeout: int = 30) -> None:
|
|
337
|
-
"""Establish a connection to PCHK at the given socket.
|
|
338
|
-
|
|
339
|
-
Ensures that the LCN bus is present and authorizes at PCHK.
|
|
340
|
-
Raise a :class:`TimeoutError`, if connection could not be established
|
|
341
|
-
within the given timeout.
|
|
342
|
-
|
|
343
|
-
:param int timeout: Timeout in seconds
|
|
344
|
-
"""
|
|
345
|
-
done: Iterable[asyncio.Future[Any]]
|
|
346
|
-
pending: Iterable[asyncio.Future[Any]]
|
|
347
|
-
done, pending = await asyncio.wait(
|
|
348
|
-
(
|
|
349
|
-
asyncio.create_task(super().async_connect()),
|
|
350
|
-
self.authentication_completed_future,
|
|
351
|
-
self.license_error_future,
|
|
352
|
-
),
|
|
353
|
-
timeout=timeout,
|
|
354
|
-
return_when=asyncio.FIRST_EXCEPTION,
|
|
355
|
-
)
|
|
339
|
+
self.fire_event(LcnEvent.BUS_DISCONNECTED)
|
|
356
340
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
341
|
+
async def ping_received(self, count: int | None) -> None:
|
|
342
|
+
"""Ping was received."""
|
|
343
|
+
if self.ping_timeout_handle is not None:
|
|
344
|
+
self.ping_timeout_handle.cancel()
|
|
345
|
+
self.last_ping = time.time()
|
|
362
346
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
raise TimeoutError(
|
|
367
|
-
f"Timeout error while connecting to {self.connection_id}."
|
|
368
|
-
)
|
|
347
|
+
def is_ready(self) -> bool:
|
|
348
|
+
"""Retrieve the overall connection state."""
|
|
349
|
+
return self.segment_scan_completed_event.is_set()
|
|
369
350
|
|
|
370
|
-
|
|
371
|
-
await self.scan_segment_couplers(
|
|
372
|
-
self.settings["SK_NUM_TRIES"], self.settings["DEFAULT_TIMEOUT_MSEC"]
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
async def async_close(self) -> None:
|
|
376
|
-
"""Close the active connection."""
|
|
377
|
-
await self.cancel_requests()
|
|
378
|
-
await super().async_close()
|
|
379
|
-
_LOGGER.debug("Connection to %s closed.", self.connection_id)
|
|
351
|
+
# Addresses, modules and groups
|
|
380
352
|
|
|
381
353
|
def set_local_seg_id(self, local_seg_id: int) -> None:
|
|
382
|
-
"""Set the local segment id.
|
|
383
|
-
|
|
384
|
-
:param int local_seg_id: The local segment_id.
|
|
385
|
-
"""
|
|
354
|
+
"""Set the local segment id."""
|
|
386
355
|
old_local_seg_id = self.local_seg_id
|
|
387
356
|
|
|
388
357
|
self.local_seg_id = local_seg_id
|
|
@@ -397,44 +366,17 @@ class PchkConnectionManager(PchkConnection):
|
|
|
397
366
|
self.address_conns[address_conn.addr] = address_conn
|
|
398
367
|
|
|
399
368
|
def physical_to_logical(self, addr: LcnAddr) -> LcnAddr:
|
|
400
|
-
"""Convert the physical segment id of an address to the logical one.
|
|
401
|
-
|
|
402
|
-
:param addr: The module's/group's address
|
|
403
|
-
:type addr: :class:`~LcnAddr`
|
|
404
|
-
|
|
405
|
-
:returns: The module's/group's address
|
|
406
|
-
:rtype: :class:`~LcnAddr`
|
|
407
|
-
"""
|
|
369
|
+
"""Convert the physical segment id of an address to the logical one."""
|
|
408
370
|
return LcnAddr(
|
|
409
371
|
self.local_seg_id if addr.seg_id in (0, 4) else addr.seg_id,
|
|
410
372
|
addr.addr_id,
|
|
411
373
|
addr.is_group,
|
|
412
374
|
)
|
|
413
375
|
|
|
414
|
-
def is_ready(self) -> bool:
|
|
415
|
-
"""Retrieve the overall connection state.
|
|
416
|
-
|
|
417
|
-
Nothing should be sent before this is signaled.
|
|
418
|
-
|
|
419
|
-
:returns: True if everything is set-up, False otherwise
|
|
420
|
-
:rtype: bool
|
|
421
|
-
"""
|
|
422
|
-
return self.segment_scan_completed_event.is_set()
|
|
423
|
-
|
|
424
376
|
def get_module_conn(
|
|
425
377
|
self, addr: LcnAddr, request_serials: bool = True
|
|
426
378
|
) -> ModuleConnection:
|
|
427
|
-
"""Create and/or return the given LCN module.
|
|
428
|
-
|
|
429
|
-
The ModuleConnection object is used for further communication
|
|
430
|
-
with the module (e.g. sending commands).
|
|
431
|
-
|
|
432
|
-
:param addr: The module's address
|
|
433
|
-
:type addr: :class:`~LcnAddr`
|
|
434
|
-
|
|
435
|
-
:returns: The address connection object (never null)
|
|
436
|
-
:rtype: `~ModuleConnection`
|
|
437
|
-
"""
|
|
379
|
+
"""Create and/or return the given LCN module."""
|
|
438
380
|
assert not addr.is_group
|
|
439
381
|
if addr.seg_id == 0 and self.local_seg_id != -1:
|
|
440
382
|
addr = LcnAddr(self.local_seg_id, addr.addr_id, addr.is_group)
|
|
@@ -450,17 +392,7 @@ class PchkConnectionManager(PchkConnection):
|
|
|
450
392
|
return address_conn
|
|
451
393
|
|
|
452
394
|
def get_group_conn(self, addr: LcnAddr) -> GroupConnection:
|
|
453
|
-
"""Create and return the GroupConnection for the given group.
|
|
454
|
-
|
|
455
|
-
The GroupConnection can be used for sending commands to all
|
|
456
|
-
modules that are static or dynamic members of the group.
|
|
457
|
-
|
|
458
|
-
:param addr: The group's address
|
|
459
|
-
:type addr: :class:`~LcnAddr`
|
|
460
|
-
|
|
461
|
-
:returns: The address connection object (never null)
|
|
462
|
-
:rtype: `~GroupConnection`
|
|
463
|
-
"""
|
|
395
|
+
"""Create and return the GroupConnection for the given group."""
|
|
464
396
|
assert addr.is_group
|
|
465
397
|
if addr.seg_id == 0 and self.local_seg_id != -1:
|
|
466
398
|
addr = LcnAddr(self.local_seg_id, addr.addr_id, addr.is_group)
|
|
@@ -469,21 +401,13 @@ class PchkConnectionManager(PchkConnection):
|
|
|
469
401
|
def get_address_conn(
|
|
470
402
|
self, addr: LcnAddr, request_serials: bool = True
|
|
471
403
|
) -> AbstractConnection:
|
|
472
|
-
"""Create and/or return an AbstractConnection to the given module or group.
|
|
473
|
-
|
|
474
|
-
The LCN module/group object is used for further communication
|
|
475
|
-
with the module/group (e.g. sending commands).
|
|
476
|
-
|
|
477
|
-
:param addr: The module's/group's address
|
|
478
|
-
:type addr: :class:`~LcnAddr`
|
|
479
|
-
|
|
480
|
-
:returns: The address connection object (never null)
|
|
481
|
-
:rtype: `~AbstractConnection`
|
|
482
|
-
"""
|
|
404
|
+
"""Create and/or return an AbstractConnection to the given module or group."""
|
|
483
405
|
if addr.is_group:
|
|
484
406
|
return self.get_group_conn(addr)
|
|
485
407
|
return self.get_module_conn(addr, request_serials)
|
|
486
408
|
|
|
409
|
+
# Other
|
|
410
|
+
|
|
487
411
|
def dump_modules(self) -> dict[str, dict[str, dict[str, Any]]]:
|
|
488
412
|
"""Dump all modules and information about them in a JSON serializable dict."""
|
|
489
413
|
dump: dict[str, dict[str, dict[str, Any]]] = {}
|
|
@@ -495,19 +419,104 @@ class PchkConnectionManager(PchkConnection):
|
|
|
495
419
|
dump[seg][addr] = address_conn.dump_details()
|
|
496
420
|
return dump
|
|
497
421
|
|
|
498
|
-
|
|
422
|
+
# Command sending / retrieval.
|
|
423
|
+
|
|
424
|
+
async def send_command(
|
|
425
|
+
self, pck: bytes | str, to_host: bool = False, **kwargs: Any
|
|
426
|
+
) -> bool:
|
|
427
|
+
"""Send a PCK command to the PCHK server."""
|
|
428
|
+
if not self.is_lcn_connected and not to_host:
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
assert self.writer is not None
|
|
432
|
+
if not self.writer.is_closing():
|
|
433
|
+
if isinstance(pck, str):
|
|
434
|
+
data = (pck + PckGenerator.TERMINATION).encode()
|
|
435
|
+
else:
|
|
436
|
+
data = pck + PckGenerator.TERMINATION.encode()
|
|
437
|
+
await self.buffer.put(data)
|
|
438
|
+
return True
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
async def process_message(self, message: str) -> None:
|
|
442
|
+
"""Is called when a new text message is received from the PCHK server."""
|
|
443
|
+
_LOGGER.debug("from %s: %s", self.connection_id, message)
|
|
444
|
+
inps = inputs.InputParser.parse(message)
|
|
445
|
+
|
|
446
|
+
if inps is not None:
|
|
447
|
+
for inp in inps:
|
|
448
|
+
await self.async_process_input(inp)
|
|
449
|
+
|
|
450
|
+
async def async_process_input(self, inp: inputs.Input) -> None:
|
|
451
|
+
"""Process an input command."""
|
|
452
|
+
# Inputs from Host
|
|
453
|
+
if isinstance(inp, inputs.AuthUsername):
|
|
454
|
+
await self.send_command(self.username, to_host=True)
|
|
455
|
+
elif isinstance(inp, inputs.AuthPassword):
|
|
456
|
+
await self.send_command(self.password, to_host=True)
|
|
457
|
+
elif isinstance(inp, inputs.AuthOk):
|
|
458
|
+
await self.on_auth(True)
|
|
459
|
+
elif isinstance(inp, inputs.AuthFailed):
|
|
460
|
+
await self.on_auth(False)
|
|
461
|
+
elif isinstance(inp, inputs.LcnConnState):
|
|
462
|
+
await self.lcn_connection_status_changed(inp.is_lcn_connected)
|
|
463
|
+
elif isinstance(inp, inputs.LicenseError):
|
|
464
|
+
await self.on_license_error()
|
|
465
|
+
elif isinstance(inp, inputs.DecModeSet):
|
|
466
|
+
self.license_error_future.set_result(True)
|
|
467
|
+
await self.on_successful_login()
|
|
468
|
+
elif isinstance(inp, inputs.CommandError):
|
|
469
|
+
_LOGGER.debug("LCN command error: %s", inp.message)
|
|
470
|
+
elif isinstance(inp, inputs.Ping):
|
|
471
|
+
await self.ping_received(inp.count)
|
|
472
|
+
elif isinstance(inp, inputs.ModSk):
|
|
473
|
+
if inp.physical_source_addr.seg_id == 0:
|
|
474
|
+
self.set_local_seg_id(inp.reported_seg_id)
|
|
475
|
+
if self.segment_coupler_response_received.locked():
|
|
476
|
+
self.segment_coupler_response_received.release()
|
|
477
|
+
# store reported segment coupler id
|
|
478
|
+
if inp.reported_seg_id not in self.segment_coupler_ids:
|
|
479
|
+
self.segment_coupler_ids.append(inp.reported_seg_id)
|
|
480
|
+
elif isinstance(inp, inputs.Unknown):
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
# Inputs from bus
|
|
484
|
+
elif self.is_ready():
|
|
485
|
+
if isinstance(inp, inputs.ModInput):
|
|
486
|
+
logical_source_addr = self.physical_to_logical(inp.physical_source_addr)
|
|
487
|
+
if not logical_source_addr.is_group:
|
|
488
|
+
module_conn = self.get_module_conn(logical_source_addr)
|
|
489
|
+
if isinstance(inp, inputs.ModSn):
|
|
490
|
+
# used to extend scan_modules() timeout
|
|
491
|
+
if self.module_serial_number_received.locked():
|
|
492
|
+
self.module_serial_number_received.release()
|
|
493
|
+
|
|
494
|
+
await module_conn.async_process_input(inp)
|
|
495
|
+
|
|
496
|
+
# Forward all known inputs to callback listeners.
|
|
497
|
+
for input_callback in self.input_callbacks:
|
|
498
|
+
input_callback(inp)
|
|
499
|
+
|
|
500
|
+
async def ping(self) -> None:
|
|
501
|
+
"""Send pings."""
|
|
502
|
+
assert self.writer is not None
|
|
503
|
+
while not self.writer.is_closing():
|
|
504
|
+
await self.send_command(f"^ping{self.ping_counter:d}", to_host=True)
|
|
505
|
+
self.ping_timeout_handle = asyncio.get_running_loop().call_later(
|
|
506
|
+
self.ping_recv_timeout, lambda: self.fire_event(LcnEvent.PING_TIMEOUT)
|
|
507
|
+
)
|
|
508
|
+
self.ping_counter += 1
|
|
509
|
+
await asyncio.sleep(self.ping_send_delay)
|
|
510
|
+
|
|
511
|
+
async def scan_modules(self, num_tries: int = 3, timeout: float = 3) -> None:
|
|
499
512
|
"""Scan for modules on the bus.
|
|
500
513
|
|
|
501
514
|
This is a convenience coroutine which handles all the logic when
|
|
502
515
|
scanning modules on the bus. Because of heavy bus traffic, not all
|
|
503
516
|
modules might respond to a scan command immediately.
|
|
504
517
|
The coroutine will make 'num_tries' attempts to send a scan command
|
|
505
|
-
and waits '
|
|
518
|
+
and waits 'timeout' after the last module response before
|
|
506
519
|
proceeding to the next try.
|
|
507
|
-
|
|
508
|
-
:param int num_tries: Scan attempts (default=3)
|
|
509
|
-
:param int timeout_msec: Timeout in msec for each try
|
|
510
|
-
(default=3000)
|
|
511
520
|
"""
|
|
512
521
|
segment_coupler_ids = (
|
|
513
522
|
self.segment_coupler_ids if self.segment_coupler_ids else [0]
|
|
@@ -529,13 +538,13 @@ class PchkConnectionManager(PchkConnection):
|
|
|
529
538
|
try:
|
|
530
539
|
await asyncio.wait_for(
|
|
531
540
|
self.module_serial_number_received.acquire(),
|
|
532
|
-
|
|
541
|
+
timeout,
|
|
533
542
|
)
|
|
534
543
|
except asyncio.TimeoutError:
|
|
535
544
|
break
|
|
536
545
|
|
|
537
546
|
async def scan_segment_couplers(
|
|
538
|
-
self, num_tries: int = 3,
|
|
547
|
+
self, num_tries: int = 3, timeout: float = 1.5
|
|
539
548
|
) -> None:
|
|
540
549
|
"""Scan for segment couplers on the bus.
|
|
541
550
|
|
|
@@ -543,12 +552,8 @@ class PchkConnectionManager(PchkConnection):
|
|
|
543
552
|
scanning segment couplers on the bus. Because of heavy bus traffic,
|
|
544
553
|
not all segment couplers might respond to a scan command immediately.
|
|
545
554
|
The coroutine will make 'num_tries' attempts to send a scan command
|
|
546
|
-
and waits '
|
|
555
|
+
and waits 'timeout' after the last segment coupler response
|
|
547
556
|
before proceeding to the next try.
|
|
548
|
-
|
|
549
|
-
:param int num_tries: Scan attempts (default=3)
|
|
550
|
-
:param int timeout_msec: Timeout in msec for each try
|
|
551
|
-
(default=3000)
|
|
552
557
|
"""
|
|
553
558
|
for _ in range(num_tries):
|
|
554
559
|
await self.send_command(
|
|
@@ -563,7 +568,7 @@ class PchkConnectionManager(PchkConnection):
|
|
|
563
568
|
try:
|
|
564
569
|
await asyncio.wait_for(
|
|
565
570
|
self.segment_coupler_response_received.acquire(),
|
|
566
|
-
|
|
571
|
+
timeout,
|
|
567
572
|
)
|
|
568
573
|
except asyncio.TimeoutError:
|
|
569
574
|
break
|
|
@@ -574,76 +579,7 @@ class PchkConnectionManager(PchkConnection):
|
|
|
574
579
|
|
|
575
580
|
self.segment_scan_completed_event.set()
|
|
576
581
|
|
|
577
|
-
|
|
578
|
-
"""Send pings."""
|
|
579
|
-
assert self.writer is not None
|
|
580
|
-
while not self.writer.is_closing():
|
|
581
|
-
await self.send_command(f"^ping{self.ping_counter:d}", to_host=True)
|
|
582
|
-
self.ping_counter += 1
|
|
583
|
-
await asyncio.sleep(self.ping_timeout)
|
|
584
|
-
|
|
585
|
-
async def process_message(self, message: str) -> None:
|
|
586
|
-
"""Is called when a new text message is received from the PCHK server.
|
|
587
|
-
|
|
588
|
-
This class should be reimplemented in any subclass which evaluates
|
|
589
|
-
received messages.
|
|
590
|
-
|
|
591
|
-
:param str input: Input text message
|
|
592
|
-
"""
|
|
593
|
-
await super().process_message(message)
|
|
594
|
-
inps = inputs.InputParser.parse(message)
|
|
595
|
-
|
|
596
|
-
if inps is not None:
|
|
597
|
-
for inp in inps:
|
|
598
|
-
await self.async_process_input(inp)
|
|
599
|
-
|
|
600
|
-
async def async_process_input(self, inp: inputs.Input) -> None:
|
|
601
|
-
"""Process an input command."""
|
|
602
|
-
# Inputs from Host
|
|
603
|
-
if isinstance(inp, inputs.AuthUsername):
|
|
604
|
-
await self.send_command(self.username, to_host=True)
|
|
605
|
-
elif isinstance(inp, inputs.AuthPassword):
|
|
606
|
-
await self.send_command(self.password, to_host=True)
|
|
607
|
-
elif isinstance(inp, inputs.AuthOk):
|
|
608
|
-
await self.on_auth(True)
|
|
609
|
-
elif isinstance(inp, inputs.AuthFailed):
|
|
610
|
-
await self.on_auth(False)
|
|
611
|
-
elif isinstance(inp, inputs.LcnConnState):
|
|
612
|
-
await self.lcn_connection_status_changed(inp.is_lcn_connected)
|
|
613
|
-
elif isinstance(inp, inputs.LicenseError):
|
|
614
|
-
await self.on_license_error()
|
|
615
|
-
elif isinstance(inp, inputs.DecModeSet):
|
|
616
|
-
self.license_error_future.set_result(True)
|
|
617
|
-
await self.on_successful_login()
|
|
618
|
-
elif isinstance(inp, inputs.CommandError):
|
|
619
|
-
_LOGGER.debug("LCN command error: %s", inp.message)
|
|
620
|
-
elif isinstance(inp, inputs.ModSk):
|
|
621
|
-
if inp.physical_source_addr.seg_id == 0:
|
|
622
|
-
self.set_local_seg_id(inp.reported_seg_id)
|
|
623
|
-
if self.segment_coupler_response_received.locked():
|
|
624
|
-
self.segment_coupler_response_received.release()
|
|
625
|
-
# store reported segment coupler id
|
|
626
|
-
if inp.reported_seg_id not in self.segment_coupler_ids:
|
|
627
|
-
self.segment_coupler_ids.append(inp.reported_seg_id)
|
|
628
|
-
elif isinstance(inp, inputs.Unknown):
|
|
629
|
-
return
|
|
630
|
-
|
|
631
|
-
# Inputs from bus
|
|
632
|
-
elif self.is_ready():
|
|
633
|
-
if isinstance(inp, inputs.ModInput):
|
|
634
|
-
logical_source_addr = self.physical_to_logical(inp.physical_source_addr)
|
|
635
|
-
if not logical_source_addr.is_group:
|
|
636
|
-
module_conn = self.get_module_conn(logical_source_addr)
|
|
637
|
-
if isinstance(inp, inputs.ModSn):
|
|
638
|
-
# used to extend scan_modules() timeout
|
|
639
|
-
if self.module_serial_number_received.locked():
|
|
640
|
-
self.module_serial_number_received.release()
|
|
641
|
-
|
|
642
|
-
await module_conn.async_process_input(inp)
|
|
643
|
-
|
|
644
|
-
# Forward all known inputs to callback listeners.
|
|
645
|
-
for input_callback in self.input_callbacks:
|
|
646
|
-
input_callback(inp)
|
|
582
|
+
# Status requests, responses
|
|
647
583
|
|
|
648
584
|
async def cancel_requests(self) -> None:
|
|
649
585
|
"""Cancel all TimeoutRetryHandlers."""
|
|
@@ -656,6 +592,8 @@ class PchkConnectionManager(PchkConnection):
|
|
|
656
592
|
if cancel_tasks:
|
|
657
593
|
await asyncio.wait(cancel_tasks)
|
|
658
594
|
|
|
595
|
+
# Callbacks for inputs and events
|
|
596
|
+
|
|
659
597
|
def register_for_inputs(
|
|
660
598
|
self, callback: Callable[[inputs.Input], None]
|
|
661
599
|
) -> Callable[..., None]:
|
|
@@ -666,20 +604,21 @@ class PchkConnectionManager(PchkConnection):
|
|
|
666
604
|
self.input_callbacks.add(callback)
|
|
667
605
|
return lambda callback=callback: self.input_callbacks.remove(callback)
|
|
668
606
|
|
|
669
|
-
def
|
|
670
|
-
"""
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
else:
|
|
674
|
-
self.event_handler = coro
|
|
607
|
+
def fire_event(self, event: LcnEvent) -> None:
|
|
608
|
+
"""Fire event."""
|
|
609
|
+
for event_callback in self.event_callbacks:
|
|
610
|
+
event_callback(event)
|
|
675
611
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
612
|
+
def register_for_events(
|
|
613
|
+
self, callback: Callable[[lcn_defs.LcnEvent], None]
|
|
614
|
+
) -> Callable[..., None]:
|
|
615
|
+
"""Register a function for callback on LCN events.
|
|
616
|
+
|
|
617
|
+
Return a function to unregister the callback.
|
|
618
|
+
"""
|
|
619
|
+
self.event_callbacks.add(callback)
|
|
620
|
+
return lambda callback=callback: self.event_callbacks.remove(callback)
|
|
621
|
+
|
|
622
|
+
def event_callback(self, event: LcnEvent) -> None:
|
|
623
|
+
"""Handle events from PchkConnection."""
|
|
624
|
+
_LOGGER.debug("%s: LCN-Event: %s", self.connection_id, event)
|