pypck 0.9.2__py3-none-any.whl → 0.9.3__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/__init__.py CHANGED
@@ -2,20 +2,22 @@
2
2
 
3
3
  from pypck import (
4
4
  connection,
5
+ device,
5
6
  helpers,
6
7
  inputs,
7
8
  lcn_addr,
8
9
  lcn_defs,
9
- module,
10
10
  pck_commands,
11
+ status_requester,
11
12
  )
12
13
 
13
14
  __all__ = [
14
15
  "connection",
15
- "inputs",
16
+ "device",
16
17
  "helpers",
18
+ "inputs",
17
19
  "lcn_addr",
18
20
  "lcn_defs",
19
- "module",
20
21
  "pck_commands",
22
+ "status_requester",
21
23
  ]
pypck/connection.py CHANGED
@@ -9,10 +9,10 @@ from types import TracebackType
9
9
  from typing import Any
10
10
 
11
11
  from pypck import inputs, lcn_defs
12
+ from pypck.device import DeviceConnection
12
13
  from pypck.helpers import TaskRegistry
13
14
  from pypck.lcn_addr import LcnAddr
14
15
  from pypck.lcn_defs import LcnEvent
15
- from pypck.module import GroupConnection, ModuleConnection
16
16
  from pypck.pck_commands import PckGenerator
17
17
 
18
18
  _LOGGER = logging.getLogger(__name__)
@@ -131,7 +131,7 @@ class PchkConnectionManager:
131
131
  # stored in this dictionary. Communication to groups is handled by
132
132
  # GroupConnection object that are created on the fly and not stored
133
133
  # permanently.
134
- self.address_conns: dict[LcnAddr, ModuleConnection] = {}
134
+ self.device_connections: dict[LcnAddr, DeviceConnection] = {}
135
135
  self.segment_coupler_ids: list[int] = []
136
136
 
137
137
  self.input_callbacks: set[Callable[[inputs.Input], None]] = set()
@@ -360,15 +360,15 @@ class PchkConnectionManager:
360
360
  old_local_seg_id = self.local_seg_id
361
361
 
362
362
  self.local_seg_id = local_seg_id
363
- # replace all address_conns with current local_seg_id with new
363
+ # replace all device_connections with current local_seg_id with new
364
364
  # local_seg_id
365
- for addr in list(self.address_conns):
365
+ for addr in list(self.device_connections):
366
366
  if addr.seg_id == old_local_seg_id:
367
- address_conn = self.address_conns.pop(addr)
367
+ address_conn = self.device_connections.pop(addr)
368
368
  address_conn.addr = LcnAddr(
369
369
  self.local_seg_id, addr.addr_id, addr.is_group
370
370
  )
371
- self.address_conns[address_conn.addr] = address_conn
371
+ self.device_connections[address_conn.addr] = address_conn
372
372
 
373
373
  def physical_to_logical(self, addr: LcnAddr) -> LcnAddr:
374
374
  """Convert the physical segment id of an address to the logical one."""
@@ -378,39 +378,28 @@ class PchkConnectionManager:
378
378
  addr.is_group,
379
379
  )
380
380
 
381
- def get_module_conn(self, addr: LcnAddr) -> ModuleConnection:
382
- """Create and/or return the given LCN module."""
383
- assert not addr.is_group
381
+ def get_device_connection(self, addr: LcnAddr) -> DeviceConnection:
382
+ """Create and/or return a connection to the given module or group."""
384
383
  if addr.seg_id == 0 and self.local_seg_id != -1:
385
384
  addr = LcnAddr(self.local_seg_id, addr.addr_id, addr.is_group)
386
- address_conn = self.address_conns.get(addr, None)
387
- if address_conn is None:
388
- address_conn = ModuleConnection(
389
- self, addr, wants_ack=self.settings["ACKNOWLEDGE"]
390
- )
391
- self.address_conns[addr] = address_conn
392
385
 
393
- return address_conn
394
-
395
- def get_group_conn(self, addr: LcnAddr) -> GroupConnection:
396
- """Create and return the GroupConnection for the given group."""
397
- assert addr.is_group
398
- if addr.seg_id == 0 and self.local_seg_id != -1:
399
- addr = LcnAddr(self.local_seg_id, addr.addr_id, addr.is_group)
400
- return GroupConnection(self, addr)
386
+ device_connection = self.device_connections.get(addr, None)
387
+ if device_connection is None:
388
+ device_connection = DeviceConnection(
389
+ self,
390
+ addr,
391
+ wants_ack=False if addr.is_group else self.settings["ACKNOWLEDGE"],
392
+ )
393
+ self.device_connections[addr] = device_connection
401
394
 
402
- def get_address_conn(self, addr: LcnAddr) -> ModuleConnection | GroupConnection:
403
- """Create and/or return a connection to the given module or group."""
404
- if addr.is_group:
405
- return self.get_group_conn(addr)
406
- return self.get_module_conn(addr)
395
+ return device_connection
407
396
 
408
397
  # Other
409
398
 
410
399
  async def dump_modules(self) -> dict[str, dict[str, dict[str, Any]]]:
411
400
  """Dump all modules and information about them in a JSON serializable dict."""
412
401
  dump: dict[str, dict[str, dict[str, Any]]] = {}
413
- for address_conn in self.address_conns.values():
402
+ for address_conn in self.device_connections.values():
414
403
  seg = f"{address_conn.addr.seg_id:d}"
415
404
  addr = f"{address_conn.addr.addr_id}"
416
405
  if seg not in dump:
@@ -484,7 +473,7 @@ class PchkConnectionManager:
484
473
  if isinstance(inp, inputs.ModInput):
485
474
  logical_source_addr = self.physical_to_logical(inp.physical_source_addr)
486
475
  if not logical_source_addr.is_group:
487
- module_conn = self.get_module_conn(logical_source_addr)
476
+ module_conn = self.get_device_connection(logical_source_addr)
488
477
  if isinstance(inp, inputs.ModSn):
489
478
  # used to extend scan_modules() timeout
490
479
  if self.module_serial_number_received.locked():
@@ -1,17 +1,18 @@
1
- """Module and group classes."""
1
+ """LCN devices: Modules and groups."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
7
  from collections.abc import Callable, Sequence
8
- from dataclasses import dataclass, field
8
+ from dataclasses import dataclass
9
9
  from typing import TYPE_CHECKING, Any, cast
10
10
 
11
11
  from pypck import inputs, lcn_defs
12
12
  from pypck.helpers import TaskRegistry
13
13
  from pypck.lcn_addr import LcnAddr
14
14
  from pypck.pck_commands import PckGenerator
15
+ from pypck.status_requester import StatusRequester
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from pypck.connection import PchkConnectionManager
@@ -29,153 +30,8 @@ class Serials:
29
30
  hardware_type: lcn_defs.HardwareType
30
31
 
31
32
 
32
- @dataclass(unsafe_hash=True)
33
- class StatusRequest:
34
- """Data class for status requests."""
35
-
36
- type: type[inputs.Input] # Type of the input expected as response
37
- parameters: frozenset[tuple[str, Any]] # {(parameter_name, parameter_value)}
38
- timestamp: float = field(
39
- compare=False
40
- ) # timestamp the response was received; -1=no timestamp
41
- response: asyncio.Future[inputs.Input] = field(
42
- compare=False
43
- ) # Future to hold the response input object
44
-
45
-
46
- class StatusRequester:
47
- """Handling of status requests."""
48
-
49
- def __init__(
50
- self,
51
- device_connection: ModuleConnection,
52
- ) -> None:
53
- """Initialize the context."""
54
- self.device_connection = device_connection
55
- self.last_requests: set[StatusRequest] = set()
56
- self.unregister_inputs = self.device_connection.register_for_inputs(
57
- self.input_callback
58
- )
59
- self.max_response_age = self.device_connection.conn.settings["MAX_RESPONSE_AGE"]
60
- # asyncio.get_running_loop().create_task(self.prune_loop())
61
-
62
- async def prune_loop(self) -> None:
63
- """Periodically prune old status requests."""
64
- while True:
65
- await asyncio.sleep(self.max_response_age)
66
- self.prune_status_requests()
67
-
68
- def prune_status_requests(self) -> None:
69
- """Prune old status requests."""
70
- entries_to_remove = {
71
- request
72
- for request in self.last_requests
73
- if asyncio.get_running_loop().time() - request.timestamp
74
- > self.max_response_age
75
- }
76
- for entry in entries_to_remove:
77
- entry.response.cancel()
78
- self.last_requests.difference_update(entries_to_remove)
79
-
80
- def get_status_requests(
81
- self,
82
- request_type: type[inputs.Input],
83
- parameters: frozenset[tuple[str, Any]] | None = None,
84
- max_age: int = 0,
85
- ) -> list[StatusRequest]:
86
- """Get the status requests for the given type and parameters."""
87
- if parameters is None:
88
- parameters = frozenset()
89
- loop = asyncio.get_running_loop()
90
- results = [
91
- request
92
- for request in self.last_requests
93
- if request.type == request_type
94
- and parameters.issubset(request.parameters)
95
- and (
96
- (request.timestamp == -1)
97
- or (max_age == -1)
98
- or (loop.time() - request.timestamp < max_age)
99
- )
100
- ]
101
- results.sort(key=lambda request: request.timestamp, reverse=True)
102
- return results
103
-
104
- def input_callback(self, inp: inputs.Input) -> None:
105
- """Handle incoming inputs and set the result for the corresponding requests."""
106
- requests = [
107
- request
108
- for request in self.get_status_requests(type(inp))
109
- if all(
110
- getattr(inp, parameter_name) == parameter_value
111
- for parameter_name, parameter_value in request.parameters
112
- )
113
- ]
114
- for request in requests:
115
- if request.response.done() or request.response.cancelled():
116
- continue
117
- request.timestamp = asyncio.get_running_loop().time()
118
- request.response.set_result(inp)
119
-
120
- async def request(
121
- self,
122
- response_type: type[inputs.Input],
123
- request_pck: str,
124
- request_acknowledge: bool = False,
125
- max_age: int = 0, # -1: no age limit / infinite age
126
- **request_kwargs: Any,
127
- ) -> inputs.Input | None:
128
- """Execute a status request and wait for the response."""
129
- parameters = frozenset(request_kwargs.items())
130
-
131
- # check if we already have a received response for the current request
132
- if requests := self.get_status_requests(response_type, parameters, max_age):
133
- try:
134
- async with asyncio.timeout(
135
- self.device_connection.conn.settings["DEFAULT_TIMEOUT"]
136
- ):
137
- return await requests[0].response
138
- except asyncio.TimeoutError:
139
- return None
140
- except asyncio.CancelledError:
141
- return None
142
-
143
- # no stored request or forced request: set up a new request
144
- request = StatusRequest(
145
- response_type,
146
- frozenset(request_kwargs.items()),
147
- -1,
148
- asyncio.get_running_loop().create_future(),
149
- )
150
-
151
- self.last_requests.discard(request)
152
- self.last_requests.add(request)
153
- result = None
154
- # send the request up to NUM_TRIES and wait for response future completion
155
- for _ in range(self.device_connection.conn.settings["NUM_TRIES"]):
156
- await self.device_connection.send_command(request_acknowledge, request_pck)
157
-
158
- try:
159
- async with asyncio.timeout(
160
- self.device_connection.conn.settings["DEFAULT_TIMEOUT"]
161
- ):
162
- # Need to shield the future. Otherwise it would get cancelled.
163
- result = await asyncio.shield(request.response)
164
- break
165
- except asyncio.TimeoutError:
166
- continue
167
- except asyncio.CancelledError:
168
- break
169
-
170
- # if we got no results, remove the request from the set
171
- if result is None:
172
- request.response.cancel()
173
- self.last_requests.discard(request)
174
- return result
175
-
176
-
177
- class AbstractConnection:
178
- """Organizes communication with a specific module.
33
+ class DeviceConnection:
34
+ """Organizes communication with a specific module/group.
179
35
 
180
36
  Sends status requests to the connection and handles status responses.
181
37
  """
@@ -193,6 +49,22 @@ class AbstractConnection:
193
49
  self.serials = Serials(-1, -1, -1, lcn_defs.HardwareType.UNKNOWN)
194
50
  self._serials_known = asyncio.Event()
195
51
 
52
+ self.input_callbacks: set[Callable[[inputs.Input], None]] = set()
53
+
54
+ # List of queued acknowledge codes from the LCN modules.
55
+ self.acknowledges: asyncio.Queue[lcn_defs.AcknowledgeErrorCode] = (
56
+ asyncio.Queue()
57
+ )
58
+
59
+ # StatusRequester
60
+ self.status_requester = StatusRequester(self)
61
+
62
+ if self.addr.is_group:
63
+ self.wants_ack = False # groups do not send acks
64
+ self._serials_known.set()
65
+ else:
66
+ self.task_registry.create_task(self._request_device_properties())
67
+
196
68
  @property
197
69
  def task_registry(self) -> TaskRegistry:
198
70
  """Get the task registry."""
@@ -216,6 +88,17 @@ class AbstractConnection:
216
88
  async def send_command(self, wants_ack: bool, pck: str | bytes) -> bool:
217
89
  """Send a command to the module represented by this class.
218
90
 
91
+ :param bool wants_ack: Also send a request for acknowledge.
92
+ :param str pck: PCK command (without header).
93
+ """
94
+ if not self.addr.is_group and wants_ack:
95
+ return await self.send_command_with_ack(pck)
96
+
97
+ return await self._send_command(wants_ack, pck)
98
+
99
+ async def _send_command(self, wants_ack: bool, pck: str | bytes) -> bool:
100
+ """Send a command to the module represented by this class.
101
+
219
102
  :param bool wants_ack: Also send a request for acknowledge.
220
103
  :param str pck: PCK command (without header).
221
104
  """
@@ -226,6 +109,50 @@ class AbstractConnection:
226
109
  return await self.conn.send_command(header + pck)
227
110
  return await self.conn.send_command(header.encode() + pck)
228
111
 
112
+ async def serials_known(self) -> None:
113
+ """Wait until the serials of this device are known."""
114
+ await self._serials_known.wait()
115
+
116
+ # ##
117
+ # ## Retry logic if an acknowledge is requested
118
+ # ##
119
+
120
+ async def send_command_with_ack(self, pck: str | bytes) -> bool:
121
+ """Send a PCK command and ensure receiving of an acknowledgement.
122
+
123
+ Resends the PCK command if no acknowledgement has been received
124
+ within timeout.
125
+
126
+ :param str pck: PCK command (without header).
127
+ :returns: True if acknowledge was received, False otherwise
128
+ :rtype: bool
129
+ """
130
+ count = 0
131
+ while count < self.conn.settings["NUM_TRIES"]:
132
+ await self._send_command(True, pck)
133
+ try:
134
+ code = await asyncio.wait_for(
135
+ self.acknowledges.get(),
136
+ timeout=self.conn.settings["DEFAULT_TIMEOUT"],
137
+ )
138
+ except asyncio.TimeoutError:
139
+ count += 1
140
+ continue
141
+ if code == lcn_defs.AcknowledgeErrorCode.OK:
142
+ return True
143
+ break
144
+ return False
145
+
146
+ async def on_ack(
147
+ self, code: lcn_defs.AcknowledgeErrorCode = lcn_defs.AcknowledgeErrorCode.OK
148
+ ) -> None:
149
+ """Is called whenever an acknowledge is received from the LCN module.
150
+
151
+ :param int code: The LCN internal code. -1 means
152
+ "positive" acknowledge
153
+ """
154
+ await self.acknowledges.put(code)
155
+
229
156
  # ##
230
157
  # ## Methods for sending PCK commands
231
158
  # ##
@@ -516,6 +443,22 @@ class AbstractConnection:
516
443
  :returns: True if command was sent successfully, False otherwise
517
444
  :rtype: bool
518
445
  """
446
+ if self.addr.is_group:
447
+ result = True
448
+ # for new modules (>=0x170206)
449
+ result &= await self.var_abs(var, value, unit, 0x170206)
450
+
451
+ # for old modules (<0x170206)
452
+ if var in [
453
+ lcn_defs.Var.TVAR,
454
+ lcn_defs.Var.R1VAR,
455
+ lcn_defs.Var.R2VAR,
456
+ lcn_defs.Var.R1VARSETPOINT,
457
+ lcn_defs.Var.R2VARSETPOINT,
458
+ ]:
459
+ result &= await self.var_abs(var, value, unit, 0x000000)
460
+ return result
461
+
519
462
  if not isinstance(value, lcn_defs.VarValue):
520
463
  value = lcn_defs.VarValue.from_var_unit(value, unit, True)
521
464
 
@@ -560,6 +503,19 @@ class AbstractConnection:
560
503
  :returns: True if command was sent successfully, False otherwise
561
504
  :rtype: bool
562
505
  """
506
+ if self.addr.is_group:
507
+ result = True
508
+ result &= await self.var_reset(var, 0x170206)
509
+ if var in [
510
+ lcn_defs.Var.TVAR,
511
+ lcn_defs.Var.R1VAR,
512
+ lcn_defs.Var.R2VAR,
513
+ lcn_defs.Var.R1VARSETPOINT,
514
+ lcn_defs.Var.R2VARSETPOINT,
515
+ ]:
516
+ result &= await self.var_reset(var, 0)
517
+ return result
518
+
563
519
  if software_serial == -1:
564
520
  await self._serials_known.wait()
565
521
  software_serial = self.serials.software_serial
@@ -586,6 +542,24 @@ class AbstractConnection:
586
542
  :returns: True if command was sent successfully, False otherwise
587
543
  :rtype: bool
588
544
  """
545
+ if self.addr.is_group:
546
+ result = True
547
+ result &= await self.var_rel(var, value, software_serial=0x170206)
548
+ if var in [
549
+ lcn_defs.Var.TVAR,
550
+ lcn_defs.Var.R1VAR,
551
+ lcn_defs.Var.R2VAR,
552
+ lcn_defs.Var.R1VARSETPOINT,
553
+ lcn_defs.Var.R2VARSETPOINT,
554
+ lcn_defs.Var.THRS1,
555
+ lcn_defs.Var.THRS2,
556
+ lcn_defs.Var.THRS3,
557
+ lcn_defs.Var.THRS4,
558
+ lcn_defs.Var.THRS5,
559
+ ]:
560
+ result &= await self.var_rel(var, value, software_serial=0)
561
+ return result
562
+
589
563
  if not isinstance(value, lcn_defs.VarValue):
590
564
  value = lcn_defs.VarValue.from_var_unit(value, unit, False)
591
565
 
@@ -773,199 +747,6 @@ class AbstractConnection:
773
747
  """
774
748
  return await self.send_command(self.wants_ack, pck)
775
749
 
776
-
777
- class GroupConnection(AbstractConnection):
778
- """Organizes communication with a specific group.
779
-
780
- It is assumed that all modules within this group are newer than FW170206
781
- """
782
-
783
- def __init__(
784
- self,
785
- conn: PchkConnectionManager,
786
- addr: LcnAddr,
787
- ):
788
- """Construct GroupConnection instance."""
789
- assert addr.is_group
790
- super().__init__(conn, addr, wants_ack=False)
791
- self._serials_known.set()
792
-
793
- async def var_abs(
794
- self,
795
- var: lcn_defs.Var,
796
- value: float | lcn_defs.VarValue,
797
- unit: lcn_defs.VarUnit = lcn_defs.VarUnit.NATIVE,
798
- software_serial: int = -1,
799
- ) -> bool:
800
- """Send a command to set the absolute value to a variable.
801
-
802
- :param Var var: Variable
803
- :param float value: Absolute value to set
804
- :param VarUnit unit: Unit of variable
805
- """
806
- result = True
807
- # for new modules (>=0x170206)
808
- result &= await super().var_abs(var, value, unit, 0x170206)
809
-
810
- # for old modules (<0x170206)
811
- if var in [
812
- lcn_defs.Var.TVAR,
813
- lcn_defs.Var.R1VAR,
814
- lcn_defs.Var.R2VAR,
815
- lcn_defs.Var.R1VARSETPOINT,
816
- lcn_defs.Var.R2VARSETPOINT,
817
- ]:
818
- result &= await super().var_abs(var, value, unit, 0x000000)
819
- return result
820
-
821
- async def var_reset(
822
- self, var: lcn_defs.Var, software_serial: int | None = None
823
- ) -> bool:
824
- """Send a command to reset the variable value.
825
-
826
- :param Var var: Variable
827
- """
828
- result = True
829
- result &= await super().var_reset(var, 0x170206)
830
- if var in [
831
- lcn_defs.Var.TVAR,
832
- lcn_defs.Var.R1VAR,
833
- lcn_defs.Var.R2VAR,
834
- lcn_defs.Var.R1VARSETPOINT,
835
- lcn_defs.Var.R2VARSETPOINT,
836
- ]:
837
- result &= await super().var_reset(var, 0)
838
- return result
839
-
840
- async def var_rel(
841
- self,
842
- var: lcn_defs.Var,
843
- value: float | lcn_defs.VarValue,
844
- unit: lcn_defs.VarUnit = lcn_defs.VarUnit.NATIVE,
845
- value_ref: lcn_defs.RelVarRef = lcn_defs.RelVarRef.CURRENT,
846
- software_serial: int = -1,
847
- ) -> bool:
848
- """Send a command to change the value of a variable.
849
-
850
- :param Var var: Variable
851
- :param float value: Relative value to add (may also be
852
- negative)
853
- :param VarUnit unit: Unit of variable
854
- """
855
- result = True
856
- result &= await super().var_rel(var, value, software_serial=0x170206)
857
- if var in [
858
- lcn_defs.Var.TVAR,
859
- lcn_defs.Var.R1VAR,
860
- lcn_defs.Var.R2VAR,
861
- lcn_defs.Var.R1VARSETPOINT,
862
- lcn_defs.Var.R2VARSETPOINT,
863
- lcn_defs.Var.THRS1,
864
- lcn_defs.Var.THRS2,
865
- lcn_defs.Var.THRS3,
866
- lcn_defs.Var.THRS4,
867
- lcn_defs.Var.THRS5,
868
- ]:
869
- result &= await super().var_rel(var, value, software_serial=0)
870
- return result
871
-
872
-
873
- class ModuleConnection(AbstractConnection):
874
- """Organizes communication with a specific module or group."""
875
-
876
- def __init__(
877
- self,
878
- conn: PchkConnectionManager,
879
- addr: LcnAddr,
880
- has_s0_enabled: bool = False,
881
- wants_ack: bool = True,
882
- ):
883
- """Construct ModuleConnection instance."""
884
- assert not addr.is_group
885
- super().__init__(conn, addr, wants_ack=wants_ack)
886
- self.has_s0_enabled = has_s0_enabled
887
-
888
- self.input_callbacks: set[Callable[[inputs.Input], None]] = set()
889
-
890
- # List of queued acknowledge codes from the LCN modules.
891
- self.acknowledges: asyncio.Queue[int] = asyncio.Queue()
892
-
893
- # StatusRequester
894
- self.status_requester = StatusRequester(self)
895
-
896
- self.task_registry.create_task(self.request_module_properties())
897
-
898
- async def request_module_properties(self) -> None:
899
- """Request module properties (serials)."""
900
- self.serials = await self.request_serials()
901
- self._serials_known.set()
902
-
903
- async def send_command(self, wants_ack: bool, pck: str | bytes) -> bool:
904
- """Send a command to the module represented by this class.
905
-
906
- :param bool wants_ack: Also send a request for acknowledge.
907
- :param str pck: PCK command (without header).
908
- """
909
- if wants_ack:
910
- return await self.send_command_with_ack(pck)
911
-
912
- return await super().send_command(False, pck)
913
-
914
- async def serials_known(self) -> None:
915
- """Wait until the serials of this module are known."""
916
- await self._serials_known.wait()
917
-
918
- # ##
919
- # ## Retry logic if an acknowledge is requested
920
- # ##
921
-
922
- async def send_command_with_ack(self, pck: str | bytes) -> bool:
923
- """Send a PCK command and ensure receiving of an acknowledgement.
924
-
925
- Resends the PCK command if no acknowledgement has been received
926
- within timeout.
927
-
928
- :param str pck: PCK command (without header).
929
- :returns: True if acknowledge was received, False otherwise
930
- :rtype: bool
931
- """
932
- count = 0
933
- while count < self.conn.settings["NUM_TRIES"]:
934
- await super().send_command(True, pck)
935
- try:
936
- code = await asyncio.wait_for(
937
- self.acknowledges.get(),
938
- timeout=self.conn.settings["DEFAULT_TIMEOUT"],
939
- )
940
- except asyncio.TimeoutError:
941
- count += 1
942
- continue
943
- if code == -1:
944
- return True
945
- break
946
- return False
947
-
948
- async def on_ack(self, code: int = -1) -> None:
949
- """Is called whenever an acknowledge is received from the LCN module.
950
-
951
- :param int code: The LCN internal code. -1 means
952
- "positive" acknowledge
953
- """
954
- await self.acknowledges.put(code)
955
-
956
- def set_s0_enabled(self, s0_enabled: bool) -> None:
957
- """Set the activation status for S0 variables.
958
-
959
- :param bool s0_enabled: If True, a BU4L has to be connected
960
- to the hardware module and S0 mode
961
- has to be activated in LCN-PRO.
962
- """
963
- self.has_s0_enabled = s0_enabled
964
-
965
- def get_s0_enabled(self) -> bool:
966
- """Get the activation status for S0 variables."""
967
- return self.has_s0_enabled
968
-
969
750
  # ##
970
751
  # ## Methods for handling input objects
971
752
  # ##
@@ -1022,12 +803,25 @@ class ModuleConnection(AbstractConnection):
1022
803
  },
1023
804
  }
1024
805
 
806
+ # ##
807
+ # ## Methods for requesting module properties and status
808
+ # ##
809
+
810
+ async def _request_device_properties(self) -> None:
811
+ """Request module properties (serials)."""
812
+ self.serials = await self.request_serials()
813
+ self._serials_known.set()
814
+
1025
815
  # Request status methods
1026
816
 
1027
817
  async def request_status_output(
1028
818
  self, output_port: lcn_defs.OutputPort, max_age: int = 0
1029
819
  ) -> inputs.ModStatusOutput | None:
1030
820
  """Request the status of an output port from a module."""
821
+ if self.addr.is_group:
822
+ _LOGGER.info("Status requests are not supported for groups.")
823
+ return None
824
+
1031
825
  result = await self.status_requester.request(
1032
826
  response_type=inputs.ModStatusOutput,
1033
827
  request_pck=PckGenerator.request_output_status(output_id=output_port.value),
@@ -1041,6 +835,10 @@ class ModuleConnection(AbstractConnection):
1041
835
  self, max_age: int = 0
1042
836
  ) -> inputs.ModStatusRelays | None:
1043
837
  """Request the status of relays from a module."""
838
+ if self.addr.is_group:
839
+ _LOGGER.info("Status requests are not supported for groups.")
840
+ return None
841
+
1044
842
  result = await self.status_requester.request(
1045
843
  response_type=inputs.ModStatusRelays,
1046
844
  request_pck=PckGenerator.request_relays_status(),
@@ -1056,6 +854,10 @@ class ModuleConnection(AbstractConnection):
1056
854
  max_age: int = 0,
1057
855
  ) -> inputs.ModStatusMotorPositionBS4 | None:
1058
856
  """Request the status of motor positions from a module."""
857
+ if self.addr.is_group:
858
+ _LOGGER.info("Status requests are not supported for groups.")
859
+ return None
860
+
1059
861
  if motor not in (
1060
862
  lcn_defs.MotorPort.MOTOR1,
1061
863
  lcn_defs.MotorPort.MOTOR2,
@@ -1083,6 +885,10 @@ class ModuleConnection(AbstractConnection):
1083
885
  self, max_age: int = 0
1084
886
  ) -> inputs.ModStatusBinSensors | None:
1085
887
  """Request the status of binary sensors from a module."""
888
+ if self.addr.is_group:
889
+ _LOGGER.info("Status requests are not supported for groups.")
890
+ return None
891
+
1086
892
  result = await self.status_requester.request(
1087
893
  response_type=inputs.ModStatusBinSensors,
1088
894
  request_pck=PckGenerator.request_bin_sensors_status(),
@@ -1097,6 +903,10 @@ class ModuleConnection(AbstractConnection):
1097
903
  max_age: int = 0,
1098
904
  ) -> inputs.ModStatusVar | None:
1099
905
  """Request the status of a variable from a module."""
906
+ if self.addr.is_group:
907
+ _LOGGER.info("Status requests are not supported for groups.")
908
+ return None
909
+
1100
910
  # do not use buffered response for old modules
1101
911
  # (variable response is typeless)
1102
912
  if self.serials.software_serial < 0x170206:
@@ -1122,6 +932,10 @@ class ModuleConnection(AbstractConnection):
1122
932
  self, max_age: int = 0
1123
933
  ) -> inputs.ModStatusLedsAndLogicOps | None:
1124
934
  """Request the status of LEDs and logic operations from a module."""
935
+ if self.addr.is_group:
936
+ _LOGGER.info("Status requests are not supported for groups.")
937
+ return None
938
+
1125
939
  result = await self.status_requester.request(
1126
940
  response_type=inputs.ModStatusLedsAndLogicOps,
1127
941
  request_pck=PckGenerator.request_leds_and_logic_ops(),
@@ -1134,6 +948,10 @@ class ModuleConnection(AbstractConnection):
1134
948
  self, max_age: int = 0
1135
949
  ) -> inputs.ModStatusKeyLocks | None:
1136
950
  """Request the status of locked keys from a module."""
951
+ if self.addr.is_group:
952
+ _LOGGER.info("Status requests are not supported for groups.")
953
+ return None
954
+
1137
955
  result = await self.status_requester.request(
1138
956
  response_type=inputs.ModStatusKeyLocks,
1139
957
  request_pck=PckGenerator.request_key_lock_status(),
@@ -1146,6 +964,10 @@ class ModuleConnection(AbstractConnection):
1146
964
 
1147
965
  async def request_serials(self, max_age: int = 0) -> Serials:
1148
966
  """Request module serials."""
967
+ if self.addr.is_group:
968
+ _LOGGER.info("Status requests are not supported for groups.")
969
+ return Serials(-1, -1, -1, lcn_defs.HardwareType.UNKNOWN)
970
+
1149
971
  result = cast(
1150
972
  inputs.ModSn | None,
1151
973
  await self.status_requester.request(
@@ -1166,6 +988,10 @@ class ModuleConnection(AbstractConnection):
1166
988
 
1167
989
  async def request_name(self, max_age: int = 0) -> str | None:
1168
990
  """Request module name."""
991
+ if self.addr.is_group:
992
+ _LOGGER.info("Status requests are not supported for groups.")
993
+ return None
994
+
1169
995
  coros = [
1170
996
  self.status_requester.request(
1171
997
  response_type=inputs.ModNameComment,
@@ -1186,6 +1012,10 @@ class ModuleConnection(AbstractConnection):
1186
1012
 
1187
1013
  async def request_comment(self, max_age: int = 0) -> str | None:
1188
1014
  """Request module name."""
1015
+ if self.addr.is_group:
1016
+ _LOGGER.info("Status requests are not supported for groups.")
1017
+ return None
1018
+
1189
1019
  coros = [
1190
1020
  self.status_requester.request(
1191
1021
  response_type=inputs.ModNameComment,
@@ -1206,6 +1036,10 @@ class ModuleConnection(AbstractConnection):
1206
1036
 
1207
1037
  async def request_oem_text(self, max_age: int = 0) -> str | None:
1208
1038
  """Request module name."""
1039
+ if self.addr.is_group:
1040
+ _LOGGER.info("Status requests are not supported for groups.")
1041
+ return None
1042
+
1209
1043
  coros = [
1210
1044
  self.status_requester.request(
1211
1045
  response_type=inputs.ModNameComment,
@@ -1228,6 +1062,10 @@ class ModuleConnection(AbstractConnection):
1228
1062
  self, dynamic: bool = False, max_age: int = 0
1229
1063
  ) -> set[LcnAddr]:
1230
1064
  """Request module static/dynamic group memberships."""
1065
+ if self.addr.is_group:
1066
+ _LOGGER.info("Status requests are not supported for groups.")
1067
+ return set()
1068
+
1231
1069
  result = await self.status_requester.request(
1232
1070
  response_type=inputs.ModStatusGroups,
1233
1071
  request_pck=(
pypck/inputs.py CHANGED
@@ -280,17 +280,15 @@ class Ping(Input):
280
280
  class ModAck(ModInput):
281
281
  """Acknowledge message received from module."""
282
282
 
283
- def __init__(self, physical_source_addr: LcnAddr, code: int):
283
+ def __init__(
284
+ self, physical_source_addr: LcnAddr, code: lcn_defs.AcknowledgeErrorCode
285
+ ):
284
286
  """Construct ModInput object."""
285
287
  super().__init__(physical_source_addr)
286
288
  self.code = code
287
289
 
288
- def get_code(self) -> int:
289
- """Return the acknowledge code.
290
-
291
- :return: Acknowledge code.
292
- :rtype: int
293
- """
290
+ def get_code(self) -> lcn_defs.AcknowledgeErrorCode:
291
+ """Return the acknowledge code."""
294
292
  return self.code
295
293
 
296
294
  @staticmethod
@@ -310,14 +308,18 @@ class ModAck(ModInput):
310
308
  addr = LcnAddr(
311
309
  int(matcher_pos.group("seg_id")), int(matcher_pos.group("mod_id"))
312
310
  )
313
- return [ModAck(addr, -1)]
311
+ return [ModAck(addr, lcn_defs.AcknowledgeErrorCode.OK)]
314
312
 
315
313
  matcher_neg = PckParser.PATTERN_ACK_NEG.match(data)
316
314
  if matcher_neg:
317
315
  addr = LcnAddr(
318
316
  int(matcher_neg.group("seg_id")), int(matcher_neg.group("mod_id"))
319
317
  )
320
- return [ModAck(addr, int(matcher_neg.group("code")))]
318
+ return [
319
+ ModAck(
320
+ addr, lcn_defs.AcknowledgeErrorCode(int(matcher_neg.group("code")))
321
+ )
322
+ ]
321
323
 
322
324
  return None
323
325
 
pypck/lcn_defs.py CHANGED
@@ -1444,6 +1444,27 @@ class AccessControlPeriphery(Enum):
1444
1444
  CODELOCK = "codelock"
1445
1445
 
1446
1446
 
1447
+ class AcknowledgeErrorCode(Enum):
1448
+ """Acknowledge error codes."""
1449
+
1450
+ UNKNOWN = -1
1451
+ OK = 0
1452
+ UNKNOWN_COMMAND = 5
1453
+ WRONG_PARAMETER_COUNT = 6
1454
+ INVALID_PARAMETER_VALUE = 7
1455
+ CURRENTLY_NOT_ALLOWED = 8
1456
+ NOT_ALLOWED_BY_PROGRAMMING = 9
1457
+ INAPPROPRIATE_MODULE = 10
1458
+ MISSING_PERIPHERY = 11
1459
+ PROGRAMMING_MODE_REQUIRED = 12
1460
+ FUSE_DEFECT = 14
1461
+
1462
+ @classmethod
1463
+ def _missing_(cls, value: Any) -> AcknowledgeErrorCode:
1464
+ """Handle missing values."""
1465
+ return cls.UNKNOWN
1466
+
1467
+
1447
1468
  class LcnEvent(Enum):
1448
1469
  """LCN events."""
1449
1470
 
@@ -0,0 +1,160 @@
1
+ """Status requester."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from pypck import inputs
11
+
12
+ if TYPE_CHECKING:
13
+ from pypck.device import DeviceConnection
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass(unsafe_hash=True)
19
+ class StatusRequest:
20
+ """Data class for status requests."""
21
+
22
+ type: type[inputs.Input] # Type of the input expected as response
23
+ parameters: frozenset[tuple[str, Any]] # {(parameter_name, parameter_value)}
24
+ timestamp: float = field(
25
+ compare=False
26
+ ) # timestamp the response was received; -1=no timestamp
27
+ response: asyncio.Future[inputs.Input] = field(
28
+ compare=False
29
+ ) # Future to hold the response input object
30
+
31
+
32
+ class StatusRequester:
33
+ """Handling of status requests."""
34
+
35
+ def __init__(
36
+ self,
37
+ device_connection: DeviceConnection,
38
+ ) -> None:
39
+ """Initialize the context."""
40
+ self.device_connection = device_connection
41
+ self.last_requests: set[StatusRequest] = set()
42
+ self.unregister_inputs = self.device_connection.register_for_inputs(
43
+ self.input_callback
44
+ )
45
+ self.max_response_age = self.device_connection.conn.settings["MAX_RESPONSE_AGE"]
46
+ # asyncio.get_running_loop().create_task(self.prune_loop())
47
+
48
+ async def prune_loop(self) -> None:
49
+ """Periodically prune old status requests."""
50
+ while True:
51
+ await asyncio.sleep(self.max_response_age)
52
+ self.prune_status_requests()
53
+
54
+ def prune_status_requests(self) -> None:
55
+ """Prune old status requests."""
56
+ entries_to_remove = {
57
+ request
58
+ for request in self.last_requests
59
+ if asyncio.get_running_loop().time() - request.timestamp
60
+ > self.max_response_age
61
+ }
62
+ for entry in entries_to_remove:
63
+ entry.response.cancel()
64
+ self.last_requests.difference_update(entries_to_remove)
65
+
66
+ def get_status_requests(
67
+ self,
68
+ request_type: type[inputs.Input],
69
+ parameters: frozenset[tuple[str, Any]] | None = None,
70
+ max_age: int = 0,
71
+ ) -> list[StatusRequest]:
72
+ """Get the status requests for the given type and parameters."""
73
+ if parameters is None:
74
+ parameters = frozenset()
75
+ loop = asyncio.get_running_loop()
76
+ results = [
77
+ request
78
+ for request in self.last_requests
79
+ if request.type == request_type
80
+ and parameters.issubset(request.parameters)
81
+ and (
82
+ (request.timestamp == -1)
83
+ or (max_age == -1)
84
+ or (loop.time() - request.timestamp < max_age)
85
+ )
86
+ ]
87
+ results.sort(key=lambda request: request.timestamp, reverse=True)
88
+ return results
89
+
90
+ def input_callback(self, inp: inputs.Input) -> None:
91
+ """Handle incoming inputs and set the result for the corresponding requests."""
92
+ requests = [
93
+ request
94
+ for request in self.get_status_requests(type(inp))
95
+ if all(
96
+ getattr(inp, parameter_name) == parameter_value
97
+ for parameter_name, parameter_value in request.parameters
98
+ )
99
+ ]
100
+ for request in requests:
101
+ if request.response.done() or request.response.cancelled():
102
+ continue
103
+ request.timestamp = asyncio.get_running_loop().time()
104
+ request.response.set_result(inp)
105
+
106
+ async def request(
107
+ self,
108
+ response_type: type[inputs.Input],
109
+ request_pck: str,
110
+ request_acknowledge: bool = False,
111
+ max_age: int = 0, # -1: no age limit / infinite age
112
+ **request_kwargs: Any,
113
+ ) -> inputs.Input | None:
114
+ """Execute a status request and wait for the response."""
115
+ parameters = frozenset(request_kwargs.items())
116
+
117
+ # check if we already have a received response for the current request
118
+ if requests := self.get_status_requests(response_type, parameters, max_age):
119
+ try:
120
+ async with asyncio.timeout(
121
+ self.device_connection.conn.settings["DEFAULT_TIMEOUT"]
122
+ ):
123
+ return await requests[0].response
124
+ except asyncio.TimeoutError:
125
+ return None
126
+ except asyncio.CancelledError:
127
+ return None
128
+
129
+ # no stored request or forced request: set up a new request
130
+ request = StatusRequest(
131
+ response_type,
132
+ frozenset(request_kwargs.items()),
133
+ -1,
134
+ asyncio.get_running_loop().create_future(),
135
+ )
136
+
137
+ self.last_requests.discard(request)
138
+ self.last_requests.add(request)
139
+ result = None
140
+ # send the request up to NUM_TRIES and wait for response future completion
141
+ for _ in range(self.device_connection.conn.settings["NUM_TRIES"]):
142
+ await self.device_connection.send_command(request_acknowledge, request_pck)
143
+
144
+ try:
145
+ async with asyncio.timeout(
146
+ self.device_connection.conn.settings["DEFAULT_TIMEOUT"]
147
+ ):
148
+ # Need to shield the future. Otherwise it would get cancelled.
149
+ result = await asyncio.shield(request.response)
150
+ break
151
+ except asyncio.TimeoutError:
152
+ continue
153
+ except asyncio.CancelledError:
154
+ break
155
+
156
+ # if we got no results, remove the request from the set
157
+ if result is None:
158
+ request.response.cancel()
159
+ self.last_requests.discard(request)
160
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypck
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: LCN-PCK library
5
5
  Home-page: https://github.com/alengwenus/pypck
6
6
  Author-email: Andre Lengwenus <alengwenus@gmail.com>
@@ -55,7 +55,7 @@ async def main():
55
55
  password="lcn",
56
56
  settings={"SK_NUM_TRIES": 0},
57
57
  ) as pck_client:
58
- module = pck_client.get_address_conn(LcnAddr(0, 10, False))
58
+ module = pck_client.get_device_connection(LcnAddr(0, 10, False))
59
59
 
60
60
  await module.dim_output(0, 100, 0)
61
61
  await asyncio.sleep(1)
@@ -0,0 +1,14 @@
1
+ pypck/__init__.py,sha256=jVx-aBsV_LmBf6jiivMrMcBUofC_AOseywDafgOzAS4,323
2
+ pypck/connection.py,sha256=n3itRe8oQtw64vyWGYhl6j4QJC6wgeeHitBSn-Cl2_4,23330
3
+ pypck/device.py,sha256=sR9FtHQ84uUjxiWBEt8qqGWoEMn9HWwuTlBLzEwUVtw,39313
4
+ pypck/helpers.py,sha256=_5doqIsSRpqdQNPIUsjFh813xKGuMuEFY6sNGobJGIk,1280
5
+ pypck/inputs.py,sha256=F7E8rprIhYzZnHARozt_hguYNgJaiNP3htrZ2E3Qa5I,45951
6
+ pypck/lcn_addr.py,sha256=N2Od8KuANOglqKjf596hJVH1SRcG7MhESKA5YYlDnbw,1946
7
+ pypck/lcn_defs.py,sha256=wSceYBwM46NqPwvff1hi8RluqUECmNY1gNcm1kDKTaI,43356
8
+ pypck/pck_commands.py,sha256=eJxmh2e8EbKGpek97L2961Kr_nVfT8rKgJCN3YgjIQM,50458
9
+ pypck/status_requester.py,sha256=10N5pbIBe_Ao-9ui_D7mCavk21BYZs9c-kxcTtmi-FI,5721
10
+ pypck-0.9.3.dist-info/licenses/LICENSE,sha256=iYB6zyMJvShfAzQE7nhYFgLzzZuBmhasLw5fYP9KRz4,1023
11
+ pypck-0.9.3.dist-info/METADATA,sha256=CprJtfVw1P-_Nb9jLPoK8lDFOMYUVI02fxH7xeIgJak,2682
12
+ pypck-0.9.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ pypck-0.9.3.dist-info/top_level.txt,sha256=59ried49iFueDa5mQ_5BGVZcESjjzi4MZZKLcganvQA,6
14
+ pypck-0.9.3.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- pypck/__init__.py,sha256=_40HMoShQbAlJT0ZdOjQagqn2NRTrvUGOmoS3cYBuIA,277
2
- pypck/connection.py,sha256=CrcvSnTn3Pbd9rwP_CBDagocJVG2DEE9KKN95ljdOVY,23839
3
- pypck/helpers.py,sha256=_5doqIsSRpqdQNPIUsjFh813xKGuMuEFY6sNGobJGIk,1280
4
- pypck/inputs.py,sha256=cjhvYKPr5QHw7Rjfkkg8LPbP8ohq_-z_Tvk51Rkx0aY,45828
5
- pypck/lcn_addr.py,sha256=N2Od8KuANOglqKjf596hJVH1SRcG7MhESKA5YYlDnbw,1946
6
- pypck/lcn_defs.py,sha256=hB4fxcjz7zkO3qkgBpWmdg8z17ZLAkZmRCyaucp70eM,42850
7
- pypck/module.py,sha256=5ytzV81BlFpXg6-lhPkbShIlq_UDb737rt5HbgjfSr8,45141
8
- pypck/pck_commands.py,sha256=eJxmh2e8EbKGpek97L2961Kr_nVfT8rKgJCN3YgjIQM,50458
9
- pypck-0.9.2.dist-info/licenses/LICENSE,sha256=iYB6zyMJvShfAzQE7nhYFgLzzZuBmhasLw5fYP9KRz4,1023
10
- pypck-0.9.2.dist-info/METADATA,sha256=r0YVKu3bk_tczrldgEEHu5vrXwJC7aOjAJMY9fA_ueg,2677
11
- pypck-0.9.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- pypck-0.9.2.dist-info/top_level.txt,sha256=59ried49iFueDa5mQ_5BGVZcESjjzi4MZZKLcganvQA,6
13
- pypck-0.9.2.dist-info/RECORD,,
File without changes