PyPlumIO 0.5.28__py3-none-any.whl → 0.5.29__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyPlumIO
3
- Version: 0.5.28
3
+ Version: 0.5.29
4
4
  Summary: PyPlumIO is a native ecoNET library for Plum ecoMAX controllers.
5
5
  Author-email: Denis Paavilainen <denpa@denpa.pro>
6
6
  License: MIT License
@@ -27,21 +27,21 @@ Requires-Dist: pyserial-asyncio ==0.6
27
27
  Requires-Dist: typing-extensions ==4.12.2
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pyplumio[docs,test] ; extra == 'dev'
30
- Requires-Dist: pre-commit ==3.8.0 ; extra == 'dev'
31
- Requires-Dist: tomli ==2.0.1 ; extra == 'dev'
30
+ Requires-Dist: pre-commit ==4.0.1 ; extra == 'dev'
31
+ Requires-Dist: tomli ==2.0.2 ; extra == 'dev'
32
32
  Provides-Extra: docs
33
- Requires-Dist: sphinx ==7.4.7 ; extra == 'docs'
34
- Requires-Dist: sphinx-rtd-theme ==2.0.0 ; extra == 'docs'
33
+ Requires-Dist: sphinx ==8.1.3 ; extra == 'docs'
34
+ Requires-Dist: sphinx-rtd-theme ==3.0.1 ; extra == 'docs'
35
35
  Requires-Dist: readthedocs-sphinx-search ==0.3.2 ; extra == 'docs'
36
36
  Provides-Extra: test
37
37
  Requires-Dist: codespell ==2.3.0 ; extra == 'test'
38
- Requires-Dist: coverage ==7.6.1 ; extra == 'test'
39
- Requires-Dist: mypy ==1.11.2 ; extra == 'test'
38
+ Requires-Dist: coverage ==7.6.4 ; extra == 'test'
39
+ Requires-Dist: mypy ==1.13.0 ; extra == 'test'
40
40
  Requires-Dist: pyserial-asyncio-fast ==0.14 ; extra == 'test'
41
- Requires-Dist: pytest ==8.3.2 ; extra == 'test'
41
+ Requires-Dist: pytest ==8.3.3 ; extra == 'test'
42
42
  Requires-Dist: pytest-asyncio ==0.24.0 ; extra == 'test'
43
- Requires-Dist: ruff ==0.6.3 ; extra == 'test'
44
- Requires-Dist: tox ==4.18.0 ; extra == 'test'
43
+ Requires-Dist: ruff ==0.7.1 ; extra == 'test'
44
+ Requires-Dist: tox ==4.23.2 ; extra == 'test'
45
45
  Requires-Dist: types-pyserial ==3.5.0.20240826 ; extra == 'test'
46
46
 
47
47
  # PyPlumIO is a native ecoNET library for Plum ecoMAX controllers.
@@ -1,29 +1,29 @@
1
1
  pyplumio/__init__.py,sha256=ditJTIOFGJDg60atHzOpiggdUrZHpSynno7MtpZUGVk,3299
2
2
  pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=CKxZe-gNrWXIa-w5PqPynJOQ9nZVzuvxxoFx7QtLKfw,413
3
+ pyplumio/_version.py,sha256=YLYDNYQKnQz5-FefYBZUB86Rwzn5G_lMcWd9gf_aubQ,413
4
4
  pyplumio/connection.py,sha256=6mUbcjGxxEhMVIbzZgCqH-Ez-fcYoRj7ZbVSzpikpNA,5949
5
5
  pyplumio/const.py,sha256=LyXa5aVy2KxnZq7H7F8s5SYsAgEC2UzZYMMRauliB2E,5502
6
6
  pyplumio/exceptions.py,sha256=Wn-y5AJ5xfaBlHhTUVKB27_0Us8_OVHqh-sicnr9sYA,700
7
- pyplumio/filters.py,sha256=r2DZHwHG0cPWzTcsIX1pjAH19BR3iCOQsaqJ3T106t4,11188
7
+ pyplumio/filters.py,sha256=KK_AV_EHy5gj9s9BNZbn9i0RnT3uZsdEg6gdve1WYrY,11152
8
8
  pyplumio/protocol.py,sha256=VRxrj8vZ1FMawqblKkyxg_V61TBSvVynd9u0JXYnMUU,8090
9
9
  pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- pyplumio/stream.py,sha256=IVCQFKBtRafRgUkr93p_wN5mXZAD3Jw1d091dfEIK20,4479
10
+ pyplumio/stream.py,sha256=mtMpnUR3TfEmL5JUGXr6GnpPGBwzCokqIKDWp4vYiVg,4654
11
11
  pyplumio/utils.py,sha256=TnBzRopinyp92wruguijxcIYmaeyNVTFX0dygI5FCMU,823
12
- pyplumio/devices/__init__.py,sha256=uKziGOX_pcd-MEt7eqdwyZidLk7-9Uu07_U49Hlel78,6532
13
- pyplumio/devices/ecomax.py,sha256=oLl6aYbgC6MtsxCiW0eLSevpXVjVOYar--fn0Qzaz9w,16878
12
+ pyplumio/devices/__init__.py,sha256=0tDa30WPvI53uSLUu2PStIgvbst-cUvYLe3SSCRHDZc,6551
13
+ pyplumio/devices/ecomax.py,sha256=CEdU7nMyJGGVNMANEwN4fgAzf6O8ufuIN0_CQcGqO3k,16886
14
14
  pyplumio/devices/ecoster.py,sha256=jNWli7ye9T6yfkcFJZhhUHH7KOv-L6AgYFp_dKyv3OM,263
15
- pyplumio/devices/mixer.py,sha256=VE9Kjpq-sTLGgR8F-qnQjOuN8BrD4edrvu1L2X6m4uM,3199
16
- pyplumio/devices/thermostat.py,sha256=1vOUWppTzY7iN6zDhNUFlb5rrqOCs4_cegn25h_bags,2609
17
- pyplumio/frames/__init__.py,sha256=QqghzVt0r1STmeDlYs_hriHfzZj96-hzRQsxlFdv6Ls,7497
15
+ pyplumio/devices/mixer.py,sha256=CnHWrJELtFgs2YTHGpQwKr2UTRdetX76OvLBA2PH-fs,3207
16
+ pyplumio/devices/thermostat.py,sha256=-CZNRyywoDU6csFu85KSmQ5woVXY0x6peXkeOsi_fqg,2617
17
+ pyplumio/frames/__init__.py,sha256=30ECFT_5IneUrpOJGxjHyeuX-i4S1ikX8Pg1HO8Yxkg,7686
18
18
  pyplumio/frames/messages.py,sha256=iDwZOPdVOZaIcEHYnkwtCazH_N6BjyEDtiJBjTRaePY,3570
19
19
  pyplumio/frames/requests.py,sha256=nbSuOLue2rI4WgtXslqTGfFnWBlwzLE6I9wraKC1uqg,6854
20
20
  pyplumio/frames/responses.py,sha256=Ch1AVBmD6Ek7BazoEMDDEa6ad_fUdUXf4bNssQOu0sI,6228
21
21
  pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,31
22
- pyplumio/helpers/data_types.py,sha256=fNQmZ-fifqNl01jDXoF0BjmBJoDKVr4spZNHBuiUVEY,9059
22
+ pyplumio/helpers/data_types.py,sha256=nB3afOLmppgSCWkZoX1-1yWPNMMNSem77x7XQ1Mi8H8,9103
23
23
  pyplumio/helpers/event_manager.py,sha256=xQOfiP_nP1Pz5zhB6HU5gXyyJXjhisYshL8_HRxDgt8,6412
24
24
  pyplumio/helpers/factory.py,sha256=6ArzJDq3MiiMaRpMEP0kC6wJWsoqOqe32V1RCxg1478,1005
25
25
  pyplumio/helpers/parameter.py,sha256=iCtKkYXJI0Zj4ifU3HCwoq_qqSE03giblSP1rqsWzcY,11463
26
- pyplumio/helpers/schedule.py,sha256=l-dQwy8TUjlPqiMFcQaMCTxzgtAvQpyuutRIdfI06zo,5302
26
+ pyplumio/helpers/schedule.py,sha256=PnVEkgthg6tHpHvZK9fXJz9VKNDyQ_7BFT4TTVEwNhI,5310
27
27
  pyplumio/helpers/task_manager.py,sha256=HAd69yGTRL0zQsu-ywnbLu1UXiJzgHWuhYWA--vs4lQ,1181
28
28
  pyplumio/helpers/timeout.py,sha256=JAhWNtIpcXyVILIwHWVy5mYofqbbRDGKLdTUKkQuajs,772
29
29
  pyplumio/helpers/uid.py,sha256=J7gN8i8LE0g6tfL66BJbwsQQqzBBxWx7giyvqaJh4BM,976
@@ -31,13 +31,13 @@ pyplumio/structures/__init__.py,sha256=EjK-5qJZ0F7lpP2b6epvTMg9cIBl4Kn91nqNkEcLw
31
31
  pyplumio/structures/alerts.py,sha256=8ievMl5_tUBlnTLCiZoIloucIngCcoAYy6uI9sSXrt0,3664
32
32
  pyplumio/structures/boiler_load.py,sha256=p3mOzZUU-g7A2tG_yp8podEqpI81hlsOZmHELyPNRY8,838
33
33
  pyplumio/structures/boiler_power.py,sha256=72qsvccg49FdRdXv2f2K5sGpjT7wAOLFjlIGWpO-DVg,901
34
- pyplumio/structures/ecomax_parameters.py,sha256=tV97N6uQ1VQmY88Rdtm_BhX2kR7tDGDOfGYFCpynX0A,27880
34
+ pyplumio/structures/ecomax_parameters.py,sha256=OYQZ0XJF-lqV_GdMaLTek4Gd6etIwhEJIyZyBS189O4,27959
35
35
  pyplumio/structures/fan_power.py,sha256=Q5fv-7_2NVuLeQPIVIylvgN7M8-a9D8rRUE0QGjyS3w,871
36
36
  pyplumio/structures/frame_versions.py,sha256=hbcVuhuPNy5qd39Vk7w4WdPCW-TNx1cAYWzA2mXocyk,1548
37
37
  pyplumio/structures/fuel_consumption.py,sha256=_p2dI4H67Eopn7IF0Gj77A8c_8lNKhhDDAtmugxLd4s,976
38
38
  pyplumio/structures/fuel_level.py,sha256=mJpp1dnRD1wXi_6EyNX7TNXosjcr905rSHOnuZ5VD74,1069
39
39
  pyplumio/structures/lambda_sensor.py,sha256=JNSCiBJoM8Uk3OGbmFIigaLOntQST5U_UrmCpaQBlM0,1595
40
- pyplumio/structures/mixer_parameters.py,sha256=4PR_BgNVpeBzR3Q29GtpF9uJnHxPZ4jgyfhyysPmAUA,8919
40
+ pyplumio/structures/mixer_parameters.py,sha256=idF3tYukfAz1EM1CE-hZBjjmGrNZN6X1MlcZr3FHrzA,9089
41
41
  pyplumio/structures/mixer_sensors.py,sha256=-cN7U-Fr2fmAQ5McQL7bZUC8CFlb1y8TN0f_dqy3UK0,2312
42
42
  pyplumio/structures/modules.py,sha256=oXUIqrOAV1dZzBV5zUH3HDUSFvNOjpUSx0TF9nZVnbs,2569
43
43
  pyplumio/structures/network_info.py,sha256=kPxmIaDGm5SyLRKVFzcrODlUtB0u5JjiZqekoKSyDpA,4159
@@ -46,15 +46,15 @@ pyplumio/structures/outputs.py,sha256=1xsJPkjN643-aFawqVoupGatUIUJfQG_g252n051Qi
46
46
  pyplumio/structures/pending_alerts.py,sha256=Uq9WpB4MW9AhDkqmDhk-g0J0h4pVq0Q50z12dYEv6kY,739
47
47
  pyplumio/structures/product_info.py,sha256=uiEN6DFQlzmBvQByTirFzXQShoex0YGdFS9WI-MAxPc,2405
48
48
  pyplumio/structures/program_version.py,sha256=R-medELYHDlk_ALsw5HOVbZRb7JD3yBUsGwqwVCjrkU,2550
49
- pyplumio/structures/regulator_data.py,sha256=wqtRWPiwC4H_98nu9g1Po5wCqqoqUMuPBE0w93vIcuI,2271
49
+ pyplumio/structures/regulator_data.py,sha256=z2mSE-cxImn8YRr_yZCcDlIbXnKdETkN7GigV5vEJqA,2265
50
50
  pyplumio/structures/regulator_data_schema.py,sha256=XM6M9ep3NyogbLPqp88mMTg8Sa9e5SFzV5I5pSYw5GY,1487
51
51
  pyplumio/structures/schedules.py,sha256=YzlfgprZq4pDfl-NBHl-EblhxatmDYr0UOkkHBW0Jok,6707
52
52
  pyplumio/structures/statuses.py,sha256=wkoynyMRr1VREwfBC6vU48kPA8ZQ83pcXuciy2xHJrk,1166
53
53
  pyplumio/structures/temperatures.py,sha256=1CDzehNmbALz1Jyt_9gZNIk52q6Wv-xQXjijVDCVYec,2337
54
- pyplumio/structures/thermostat_parameters.py,sha256=EzJVZZhZ19beshRG7X6lw9VWDdq4mQoMFSOhWuW7_0U,8064
54
+ pyplumio/structures/thermostat_parameters.py,sha256=SewPHuw7f4PTvzKXcOQA8SNjQyuyBWNIk6jsUJX82BI,8321
55
55
  pyplumio/structures/thermostat_sensors.py,sha256=8e1TxYIJTQKT0kIGO9gG4hGdLOBUpIhiPToQyOMyeNE,3237
56
- PyPlumIO-0.5.28.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
- PyPlumIO-0.5.28.dist-info/METADATA,sha256=eFyYRpMwxZF6tmMLwiG2_5HZeacgq7ndFuzBSK8luAE,5490
58
- PyPlumIO-0.5.28.dist-info/WHEEL,sha256=cVxcB9AmuTcXqmwrtPhNK88dr7IR_b6qagTj0UvIEbY,91
59
- PyPlumIO-0.5.28.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
- PyPlumIO-0.5.28.dist-info/RECORD,,
56
+ PyPlumIO-0.5.29.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
+ PyPlumIO-0.5.29.dist-info/METADATA,sha256=lRGUv-VdjhGInA3PozigXBjMGH8mJg_TquWmaUSvdFA,5490
58
+ PyPlumIO-0.5.29.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
59
+ PyPlumIO-0.5.29.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
+ PyPlumIO-0.5.29.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (74.1.2)
2
+ Generator: setuptools (75.3.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
pyplumio/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.5.28'
16
- __version_tuple__ = version_tuple = (0, 5, 28)
15
+ __version__ = version = '0.5.29'
16
+ __version_tuple__ = version_tuple = (0, 5, 29)
@@ -45,7 +45,7 @@ class Device(ABC, EventManager):
45
45
 
46
46
  queue: asyncio.Queue[Frame]
47
47
 
48
- def __init__(self, queue: asyncio.Queue[Frame]):
48
+ def __init__(self, queue: asyncio.Queue[Frame]) -> None:
49
49
  """Initialize a new device."""
50
50
  super().__init__()
51
51
  self.queue = queue
@@ -126,14 +126,14 @@ class PhysicalDevice(Device, ABC):
126
126
  _network: NetworkInfo
127
127
  _setup_frames: tuple[DataFrameDescription, ...]
128
128
 
129
- def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo):
129
+ def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo) -> None:
130
130
  """Initialize a new physical device."""
131
131
  super().__init__(queue)
132
132
  self._network = network
133
133
 
134
134
  def handle_frame(self, frame: Frame) -> None:
135
135
  """Handle frame received from the device."""
136
- frame.sender_device = self
136
+ frame.assign_to(self)
137
137
  if frame.data is not None:
138
138
  for name, value in frame.data.items():
139
139
  self.dispatch_nowait(name, value)
@@ -188,7 +188,7 @@ class VirtualDevice(Device, ABC):
188
188
 
189
189
  def __init__(
190
190
  self, queue: asyncio.Queue[Frame], parent: PhysicalDevice, index: int = 0
191
- ):
191
+ ) -> None:
192
192
  """Initialize a new sub-device."""
193
193
  super().__init__(queue)
194
194
  self.parent = parent
@@ -110,7 +110,7 @@ class EcoMAX(PhysicalDevice):
110
110
  _fuel_burned_timestamp_ns: int
111
111
  _setup_frames = SETUP_FRAME_TYPES
112
112
 
113
- def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo):
113
+ def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo) -> None:
114
114
  """Initialize a new ecoMAX controller."""
115
115
  super().__init__(queue, network)
116
116
  self._frame_versions = {}
pyplumio/devices/mixer.py CHANGED
@@ -30,7 +30,7 @@ class Mixer(VirtualDevice):
30
30
 
31
31
  def __init__(
32
32
  self, queue: asyncio.Queue[Frame], parent: PhysicalDevice, index: int = 0
33
- ):
33
+ ) -> None:
34
34
  """Initialize a new mixer."""
35
35
  super().__init__(queue, parent, index)
36
36
  self.subscribe(ATTR_MIXER_SENSORS, self._handle_mixer_sensors)
@@ -26,7 +26,7 @@ class Thermostat(VirtualDevice):
26
26
 
27
27
  def __init__(
28
28
  self, queue: asyncio.Queue[Frame], parent: PhysicalDevice, index: int = 0
29
- ):
29
+ ) -> None:
30
30
  """Initialize a new thermostat."""
31
31
  super().__init__(queue, parent, index)
32
32
  self.subscribe(ATTR_THERMOSTAT_SENSORS, self._handle_thermostat_sensors)
pyplumio/filters.py CHANGED
@@ -66,13 +66,12 @@ def _significantly_changed(
66
66
  def _significantly_changed(old: Comparable, new: Comparable) -> bool:
67
67
  """Check if value is significantly changed."""
68
68
  if isinstance(old, Parameter) and isinstance(new, Parameter):
69
- result = new.pending_update or old.values != new.values
70
- elif isinstance(old, SupportsFloat) and isinstance(new, SupportsFloat):
71
- result = not math.isclose(old, new, abs_tol=TOLERANCE)
72
- else:
73
- result = old != new
69
+ return new.pending_update or old.values.__ne__(new.values)
74
70
 
75
- return result
71
+ if isinstance(old, SupportsFloat) and isinstance(new, SupportsFloat):
72
+ return not math.isclose(old, new, abs_tol=TOLERANCE)
73
+
74
+ return old.__ne__(new)
76
75
 
77
76
 
78
77
  @overload
@@ -91,10 +90,11 @@ def _diffence_between(
91
90
  """Return a difference between values."""
92
91
  if isinstance(old, list) and isinstance(new, list):
93
92
  return [x for x in new if x not in old]
94
- elif isinstance(old, SupportsSubtraction) and isinstance(new, SupportsSubtraction):
95
- return new - old
96
- else:
97
- return None
93
+
94
+ if isinstance(old, SupportsSubtraction) and isinstance(new, SupportsSubtraction):
95
+ return new.__sub__(old)
96
+
97
+ return None
98
98
 
99
99
 
100
100
  class Filter(ABC):
@@ -24,6 +24,7 @@ ECONET_VERSION: Final = 5
24
24
 
25
25
  # Frame header structure.
26
26
  struct_header = struct.Struct("<BH4B")
27
+ HEADER_SIZE = struct_header.size
27
28
 
28
29
  if TYPE_CHECKING:
29
30
  from pyplumio.devices import PhysicalDevice
@@ -73,22 +74,20 @@ class Frame(ABC):
73
74
 
74
75
  __slots__ = (
75
76
  "recipient",
76
- "recipient_device",
77
77
  "sender",
78
- "sender_device",
79
78
  "econet_type",
80
79
  "econet_version",
80
+ "_handler",
81
81
  "_message",
82
82
  "_data",
83
83
  )
84
84
 
85
85
  recipient: DeviceType
86
- recipient_device: PhysicalDevice | None
87
86
  sender: DeviceType
88
- sender_device: PhysicalDevice | None
89
87
  econet_type: int
90
88
  econet_version: int
91
89
  frame_type: ClassVar[FrameType]
90
+ _handler: PhysicalDevice | None
92
91
  _message: bytearray | None
93
92
  _data: dict[str, Any] | None
94
93
 
@@ -104,11 +103,10 @@ class Frame(ABC):
104
103
  ) -> None:
105
104
  """Process a frame data and message."""
106
105
  self.recipient = recipient
107
- self.recipient_device = None
108
106
  self.sender = sender
109
- self.sender_device = None
110
107
  self.econet_type = econet_type
111
108
  self.econet_version = econet_version
109
+ self._handler = None
112
110
  self._data = data if not kwargs else ensure_dict(data, kwargs)
113
111
  self._message = message
114
112
 
@@ -153,6 +151,15 @@ class Frame(ABC):
153
151
  """Return a frame message represented as hex string."""
154
152
  return self.bytes.hex(*args, **kwargs)
155
153
 
154
+ def assign_to(self, device: PhysicalDevice) -> None:
155
+ """Assign device to the frame."""
156
+ self._handler = device
157
+
158
+ @property
159
+ def handler(self) -> PhysicalDevice | None:
160
+ """Return the device associated to the frame."""
161
+ return self._handler
162
+
156
163
  @property
157
164
  def data(self) -> dict[str, Any]:
158
165
  """Return the frame data."""
@@ -19,7 +19,7 @@ class DataType(ABC, Generic[T]):
19
19
  _value: T
20
20
  _size: int
21
21
 
22
- def __init__(self, value: T | None = None):
22
+ def __init__(self, value: T | None = None) -> None:
23
23
  """Initialize a new data type."""
24
24
  if value is not None:
25
25
  self._value = value
@@ -112,7 +112,7 @@ class BitArray(DataType[int]):
112
112
 
113
113
  _index: int
114
114
 
115
- def __init__(self, value: bool | None = None, index: int = 0):
115
+ def __init__(self, value: bool | None = None, index: int = 0) -> None:
116
116
  """Initialize a new bit array."""
117
117
  super().__init__(value)
118
118
  self._index = index
@@ -199,7 +199,7 @@ class String(DataType[str]):
199
199
 
200
200
  __slots__ = ()
201
201
 
202
- def __init__(self, value: str = ""):
202
+ def __init__(self, value: str = "") -> None:
203
203
  """Initialize a new null-terminated string data type."""
204
204
  super().__init__(value)
205
205
  self._size = len(self.value) + 1
@@ -219,7 +219,7 @@ class VarBytes(DataType[bytes]):
219
219
 
220
220
  __slots__ = ()
221
221
 
222
- def __init__(self, value: bytes = b""):
222
+ def __init__(self, value: bytes = b"") -> None:
223
223
  """Initialize a new variable-length bytes data type."""
224
224
  super().__init__(value)
225
225
  self._size = len(value) + 1
@@ -239,7 +239,7 @@ class VarString(DataType[str]):
239
239
 
240
240
  __slots__ = ()
241
241
 
242
- def __init__(self, value: str = ""):
242
+ def __init__(self, value: str = "") -> None:
243
243
  """Initialize a new variable length bytes data type."""
244
244
  super().__init__(value)
245
245
  self._size = len(value) + 1
@@ -326,7 +326,7 @@ class UnsignedInt(BuiltInDataType[int]):
326
326
  _struct = struct.Struct("<I")
327
327
 
328
328
 
329
- class Float(BuiltInDataType[int]):
329
+ class Float(BuiltInDataType[float]):
330
330
  """Represents a float."""
331
331
 
332
332
  __slots__ = ()
@@ -334,7 +334,7 @@ class Float(BuiltInDataType[int]):
334
334
  _struct = struct.Struct("<f")
335
335
 
336
336
 
337
- class Double(BuiltInDataType[int]):
337
+ class Double(BuiltInDataType[float]):
338
338
  """Represents a double."""
339
339
 
340
340
  __slots__ = ()
@@ -70,7 +70,7 @@ class ScheduleDay(MutableMapping):
70
70
 
71
71
  _intervals: list[bool]
72
72
 
73
- def __init__(self, intervals: list[bool]):
73
+ def __init__(self, intervals: list[bool]) -> None:
74
74
  """Initialize a new schedule day."""
75
75
  self._intervals = intervals
76
76
 
pyplumio/stream.py CHANGED
@@ -10,7 +10,14 @@ from typing import Final, NamedTuple
10
10
  from pyplumio.const import DeviceType
11
11
  from pyplumio.devices import is_known_device_type
12
12
  from pyplumio.exceptions import ChecksumError, ReadError, UnknownDeviceError
13
- from pyplumio.frames import FRAME_START, Frame, bcc, struct_header
13
+ from pyplumio.frames import (
14
+ DELIMITER_SIZE,
15
+ FRAME_START,
16
+ HEADER_SIZE,
17
+ Frame,
18
+ bcc,
19
+ struct_header,
20
+ )
14
21
  from pyplumio.helpers.timeout import timeout
15
22
 
16
23
  READER_TIMEOUT: Final = 10
@@ -29,7 +36,7 @@ class FrameWriter:
29
36
 
30
37
  _writer: StreamWriter
31
38
 
32
- def __init__(self, writer: StreamWriter):
39
+ def __init__(self, writer: StreamWriter) -> None:
33
40
  """Initialize a new frame writer."""
34
41
  self._writer = writer
35
42
 
@@ -46,7 +53,7 @@ class FrameWriter:
46
53
  self._writer.close()
47
54
  await self.wait_closed()
48
55
  except (OSError, asyncio.TimeoutError):
49
- _LOGGER.exception("Unexpected error while closing the writer")
56
+ _LOGGER.exception("Unexpected error, while closing the writer")
50
57
 
51
58
  @timeout(WRITER_TIMEOUT)
52
59
  async def wait_closed(self) -> None:
@@ -57,8 +64,6 @@ class FrameWriter:
57
64
  class Header(NamedTuple):
58
65
  """Represents a frame header."""
59
66
 
60
- bytes: bytes
61
- frame_start: int
62
67
  frame_length: int
63
68
  recipient: int
64
69
  sender: int
@@ -73,25 +78,28 @@ class FrameReader:
73
78
 
74
79
  _reader: StreamReader
75
80
 
76
- def __init__(self, reader: StreamReader):
81
+ def __init__(self, reader: StreamReader) -> None:
77
82
  """Initialize a new frame reader."""
78
83
  self._reader = reader
79
84
 
80
- async def _read_header(self) -> Header:
85
+ async def _read_header(self) -> tuple[Header, bytes]:
81
86
  """Locate and read a frame header.
82
87
 
83
88
  Raise pyplumio.ReadError if header size is too small and
84
89
  OSError if serial connection is broken.
85
90
  """
86
- while buffer := await self._reader.read(1):
91
+ while buffer := await self._reader.read(DELIMITER_SIZE):
87
92
  if FRAME_START not in buffer:
88
93
  continue
89
94
 
90
- buffer += await self._reader.read(struct_header.size - 1)
91
- if len(buffer) < struct_header.size:
92
- raise ReadError(f"Header can't be less than {struct_header.size} bytes")
95
+ try:
96
+ buffer += await self._reader.readexactly(HEADER_SIZE - DELIMITER_SIZE)
97
+ except IncompleteReadError as e:
98
+ raise ReadError(
99
+ f"Got incomplete header, while trying to read {e.expected} bytes"
100
+ ) from e
93
101
 
94
- return Header(buffer, *struct_header.unpack_from(buffer))
102
+ return Header(*struct_header.unpack_from(buffer)[DELIMITER_SIZE:]), buffer
95
103
 
96
104
  raise OSError("Serial connection broken")
97
105
 
@@ -99,21 +107,16 @@ class FrameReader:
99
107
  async def read(self) -> Frame | None:
100
108
  """Read the frame and return corresponding handler object.
101
109
 
102
- Raise pyplumio.ReadError on unexpected frame length or
103
- incomplete frame and pyplumio. Raise ChecksumError on incorrect
104
- frame checksum.
110
+ Raise pyplumio.UnknownDeviceError when sender device has an
111
+ unknown address, raise pyplumio.ReadError on unexpected frame
112
+ length or incomplete frame, raise pyplumio.ChecksumError on
113
+ incorrect frame checksum.
105
114
  """
106
- (
107
- header_bytes,
108
- _,
109
- frame_length,
110
- recipient,
111
- sender,
112
- econet_type,
113
- econet_version,
114
- ) = await self._read_header()
115
+ header, buffer = await self._read_header()
116
+ frame_length, recipient, sender, econet_type, econet_version = header
115
117
 
116
118
  if recipient not in (DeviceType.ECONET, DeviceType.ALL):
119
+ # Not an intended recipient, ignore the frame.
117
120
  return None
118
121
 
119
122
  if not is_known_device_type(sender):
@@ -123,25 +126,24 @@ class FrameReader:
123
126
  raise ReadError(f"Unexpected frame length ({frame_length})")
124
127
 
125
128
  try:
126
- payload = await self._reader.readexactly(frame_length - struct_header.size)
129
+ buffer += await self._reader.readexactly(frame_length - HEADER_SIZE)
127
130
  except IncompleteReadError as e:
128
131
  raise ReadError(
129
- "Got an incomplete frame while trying to read "
130
- + f"'{frame_length - struct_header.size}' bytes"
132
+ f"Got incomplete frame, while trying to read {e.expected} bytes"
131
133
  ) from e
132
134
 
133
- if (checksum := bcc(header_bytes + payload[:-2])) and checksum != payload[-2]:
135
+ if (checksum := bcc(buffer[:-2])) and checksum != buffer[-2]:
134
136
  raise ChecksumError(
135
- f"Incorrect frame checksum ({checksum} != {payload[-2]})"
137
+ f"Incorrect frame checksum ({checksum} != {buffer[-2]})"
136
138
  )
137
139
 
138
140
  frame = await Frame.create(
139
- frame_type=payload[0],
141
+ frame_type=buffer[HEADER_SIZE],
140
142
  recipient=DeviceType(recipient),
141
143
  sender=DeviceType(sender),
142
144
  econet_type=econet_type,
143
145
  econet_version=econet_version,
144
- message=payload[1:-2],
146
+ message=buffer[HEADER_SIZE + 1 : -2],
145
147
  )
146
148
  _LOGGER.debug("Received frame: %s", frame)
147
149
 
@@ -60,12 +60,13 @@ class EcomaxParameter(Parameter):
60
60
  """Create a request to change the parameter."""
61
61
  handler = partial(Request.create, recipient=self.device.address)
62
62
  if self.description.name == ATTR_ECOMAX_CONTROL:
63
- request = await handler(
63
+ return await handler(
64
64
  frame_type=FrameType.REQUEST_ECOMAX_CONTROL,
65
65
  data={ATTR_VALUE: self.values.value},
66
66
  )
67
- elif self.description.name == ATTR_THERMOSTAT_PROFILE:
68
- request = await handler(
67
+
68
+ if self.description.name == ATTR_THERMOSTAT_PROFILE:
69
+ return await handler(
69
70
  frame_type=FrameType.REQUEST_SET_THERMOSTAT_PARAMETER,
70
71
  data={
71
72
  ATTR_INDEX: self._index,
@@ -74,13 +75,11 @@ class EcomaxParameter(Parameter):
74
75
  ATTR_SIZE: 1,
75
76
  },
76
77
  )
77
- else:
78
- request = await handler(
79
- frame_type=FrameType.REQUEST_SET_ECOMAX_PARAMETER,
80
- data={ATTR_INDEX: self._index, ATTR_VALUE: self.values.value},
81
- )
82
78
 
83
- return request
79
+ return await handler(
80
+ frame_type=FrameType.REQUEST_SET_ECOMAX_PARAMETER,
81
+ data={ATTR_INDEX: self._index, ATTR_VALUE: self.values.value},
82
+ )
84
83
 
85
84
 
86
85
  @dataslots
@@ -90,6 +89,7 @@ class EcomaxNumberDescription(EcomaxParameterDescription, NumberDescription):
90
89
 
91
90
  multiplier: float = 1.0
92
91
  offset: int = 0
92
+ precision: int = 6
93
93
 
94
94
 
95
95
  class EcomaxNumber(EcomaxParameter, Number):
@@ -103,29 +103,27 @@ class EcomaxNumber(EcomaxParameter, Number):
103
103
  self, value: float | int, retries: int = 5, timeout: float = 5.0
104
104
  ) -> bool:
105
105
  """Set a parameter value."""
106
- value = (value + self.description.offset) / self.description.multiplier
106
+ value += self.description.offset
107
+ value = round(value / self.description.multiplier, self.description.precision)
107
108
  return await super().set(value, retries, timeout)
108
109
 
109
110
  @property
110
111
  def value(self) -> float:
111
112
  """Return the value."""
112
- return (
113
- self.values.value - self.description.offset
114
- ) * self.description.multiplier
113
+ value = self.values.value - self.description.offset
114
+ return round(value * self.description.multiplier, self.description.precision)
115
115
 
116
116
  @property
117
117
  def min_value(self) -> float:
118
118
  """Return the minimum allowed value."""
119
- return (
120
- self.values.min_value - self.description.offset
121
- ) * self.description.multiplier
119
+ value = self.values.min_value - self.description.offset
120
+ return round(value * self.description.multiplier, self.description.precision)
122
121
 
123
122
  @property
124
123
  def max_value(self) -> float:
125
124
  """Return the maximum allowed value."""
126
- return (
127
- self.values.max_value - self.description.offset
128
- ) * self.description.multiplier
125
+ value = self.values.max_value - self.description.offset
126
+ return round(value * self.description.multiplier, self.description.precision)
129
127
 
130
128
 
131
129
  @dataslots
@@ -292,11 +290,11 @@ ECOMAX_PARAMETERS: dict[ProductType, tuple[EcomaxParameterDescription, ...]] = {
292
290
  unit_of_measurement=UnitOfMeasurement.CELSIUS,
293
291
  ),
294
292
  EcomaxNumberDescription(
295
- name="supervision_airflow_work",
293
+ name="grate_fan_work",
296
294
  unit_of_measurement=UnitOfMeasurement.SECONDS,
297
295
  ),
298
296
  EcomaxNumberDescription(
299
- name="supervision_airflow_pause",
297
+ name="grate_fan_pause",
300
298
  unit_of_measurement=UnitOfMeasurement.MINUTES,
301
299
  ),
302
300
  EcomaxNumberDescription(
@@ -416,10 +414,10 @@ ECOMAX_PARAMETERS: dict[ProductType, tuple[EcomaxParameterDescription, ...]] = {
416
414
  unit_of_measurement=PERCENTAGE,
417
415
  ),
418
416
  EcomaxNumberDescription(
419
- name="burning_off_airflow_work",
417
+ name="burning_off_fan_work",
420
418
  ),
421
419
  EcomaxNumberDescription(
422
- name="burning_off_airflow_pause",
420
+ name="burning_off_fan_pause",
423
421
  ),
424
422
  EcomaxNumberDescription(
425
423
  name="start_burning_off",
@@ -73,6 +73,7 @@ class MixerNumberDescription(MixerParameterDescription, NumberDescription):
73
73
 
74
74
  multiplier: float = 1.0
75
75
  offset: int = 0
76
+ precision: int = 6
76
77
 
77
78
 
78
79
  class MixerNumber(MixerParameter, Number):
@@ -86,29 +87,27 @@ class MixerNumber(MixerParameter, Number):
86
87
  self, value: int | float, retries: int = 5, timeout: float = 5.0
87
88
  ) -> bool:
88
89
  """Set a parameter value."""
89
- value = (value + self.description.offset) / self.description.multiplier
90
+ value += self.description.offset
91
+ value = round(value / self.description.multiplier, self.description.precision)
90
92
  return await super().set(value, retries, timeout)
91
93
 
92
94
  @property
93
95
  def value(self) -> float:
94
96
  """Return the parameter value."""
95
- return (
96
- self.values.value - self.description.offset
97
- ) * self.description.multiplier
97
+ value = self.values.value - self.description.offset
98
+ return round(value * self.description.multiplier, self.description.precision)
98
99
 
99
100
  @property
100
101
  def min_value(self) -> float:
101
102
  """Return the minimum allowed value."""
102
- return (
103
- self.values.min_value - self.description.offset
104
- ) * self.description.multiplier
103
+ value = self.values.min_value - self.description.offset
104
+ return round(value * self.description.multiplier, self.description.precision)
105
105
 
106
106
  @property
107
107
  def max_value(self) -> float:
108
108
  """Return the maximum allowed value."""
109
- return (
110
- self.values.max_value - self.description.offset
111
- ) * self.description.multiplier
109
+ value = self.values.max_value - self.description.offset
110
+ return round(value * self.description.multiplier, self.description.precision)
112
111
 
113
112
 
114
113
  @dataslots
@@ -53,7 +53,7 @@ class RegulatorDataStructure(StructureDecoder):
53
53
  message, offset + 2, data
54
54
  )
55
55
 
56
- if (device := self.frame.sender_device) is not None and (
56
+ if (device := self.frame.handler) is not None and (
57
57
  schema := device.get_nowait(ATTR_REGDATA_SCHEMA, [])
58
58
  ):
59
59
  self._bitarray_index = 0
@@ -66,7 +66,7 @@ class ThermostatParameter(Parameter):
66
66
  values: ParameterValues | None = None,
67
67
  index: int = 0,
68
68
  offset: int = 0,
69
- ):
69
+ ) -> None:
70
70
  """Initialize a new thermostat parameter."""
71
71
  self.offset = offset
72
72
  super().__init__(device, description, values, index)
@@ -93,6 +93,7 @@ class ThermostatNumberDescription(ThermostatParameterDescription, NumberDescript
93
93
  """Represent a thermostat number description."""
94
94
 
95
95
  multiplier: float = 1.0
96
+ precision: int = 6
96
97
 
97
98
 
98
99
  class ThermostatNumber(ThermostatParameter, Number):
@@ -106,23 +107,31 @@ class ThermostatNumber(ThermostatParameter, Number):
106
107
  self, value: int | float, retries: int = 5, timeout: float = 5.0
107
108
  ) -> bool:
108
109
  """Set a parameter value."""
109
- value = value / self.description.multiplier
110
+ value = round(value / self.description.multiplier, self.description.precision)
110
111
  return await super().set(value, retries, timeout)
111
112
 
112
113
  @property
113
114
  def value(self) -> float:
114
115
  """Return the value."""
115
- return self.values.value * self.description.multiplier
116
+ return round(
117
+ self.values.value * self.description.multiplier, self.description.precision
118
+ )
116
119
 
117
120
  @property
118
121
  def min_value(self) -> float:
119
122
  """Return the minimum allowed value."""
120
- return self.values.min_value * self.description.multiplier
123
+ return round(
124
+ self.values.min_value * self.description.multiplier,
125
+ self.description.precision,
126
+ )
121
127
 
122
128
  @property
123
129
  def max_value(self) -> float:
124
130
  """Return the maximum allowed value."""
125
- return self.values.max_value * self.description.multiplier
131
+ return round(
132
+ self.values.max_value * self.description.multiplier,
133
+ self.description.precision,
134
+ )
126
135
 
127
136
 
128
137
  @dataslots
@@ -247,7 +256,7 @@ class ThermostatParametersStructure(StructureDecoder):
247
256
  self, message: bytearray, offset: int = 0, data: dict[str, Any] | None = None
248
257
  ) -> tuple[dict[str, Any], int]:
249
258
  """Decode bytes and return message data and offset."""
250
- if (device := self.frame.sender_device) is not None and (
259
+ if (device := self.frame.handler) is not None and (
251
260
  thermostats := device.get_nowait(ATTR_THERMOSTATS_AVAILABLE, 0)
252
261
  ) == 0:
253
262
  return (