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 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 deque
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 PchkLcnNotConnectedError(Exception):
49
- """Exception which is raised if there is no connection to the LCN bus."""
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 = "LCN not connected."
51
+ message = "Connection refused"
55
52
  super().__init__(message)
56
53
 
57
54
 
58
- class PchkConnection:
59
- """Socket connection to LCN-PCHK server.
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
- async def async_close(self) -> None:
179
- """Close the active connection."""
180
- await self.task_registry.cancel_all_tasks()
181
- if self.writer:
182
- self.writer.close()
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
- class PchkConnectionManager(PchkConnection):
202
- """Connection to LCN-PCHK.
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
- :param str host: Server IP address formatted as
212
- xxx.xxx.xxx.xxx
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
- An example how to setup a proper connection to PCHK including login and
218
- (automatic) segment coupler scan is shown below.
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
- super().__init__(host, port, connection_id)
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.ping_timeout = self.settings["PING_TIMEOUT"] / 1000 # seconds
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: asyncio.Future[bool] = asyncio.Future()
255
- self.license_error_future: asyncio.Future[bool] = asyncio.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.task_registry.create_task(
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.task_registry.create_task(self.event_handler("lcn-connected"))
336
+ self.fire_event(LcnEvent.BUS_CONNECTED)
332
337
  else:
333
338
  _LOGGER.debug("%s: LCN is not connected.", self.connection_id)
334
- self.task_registry.create_task(self.event_handler("lcn-disconnected"))
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
- # Raise any exception which occurs
358
- # (ConnectionRefusedError, PchkAuthenticationError, PchkLicenseError)
359
- for awaitable in done:
360
- if awaitable.exception():
361
- raise awaitable.exception() # type: ignore
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
- if pending:
364
- for task in pending:
365
- task.cancel()
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
- # start segment scan
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
- async def scan_modules(self, num_tries: int = 3, timeout_msec: int = 3000) -> None:
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 'timeout_msec' after the last module response before
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
- timeout_msec / 1000,
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, timeout_msec: int = 1500
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 'timeout_msec' after the last segment coupler response
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
- timeout_msec / 1000,
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
- async def ping(self) -> None:
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 set_event_handler(self, coro: Callable[[str], Awaitable[None]]) -> None:
670
- """Set the event handler for specific LCN events."""
671
- if coro is None:
672
- self.event_handler = self.default_event_handler
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
- async def default_event_handler(self, event: str) -> None:
677
- """Handle events for specific LCN events."""
678
- if event == "lcn-connected":
679
- pass
680
- elif event == "lcn-disconnected":
681
- pass
682
- elif event == "lcn-connection-status-changed":
683
- pass
684
- elif event == "connection-lost":
685
- pass
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)