PyPlumIO 0.5.20__py3-none-any.whl → 0.5.22__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.20
3
+ Version: 0.5.22
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
@@ -28,18 +28,18 @@ Requires-Dist: pyplumio[docs,test] ; extra == 'dev'
28
28
  Requires-Dist: pre-commit ==3.7.1 ; extra == 'dev'
29
29
  Requires-Dist: tomli ==2.0.1 ; extra == 'dev'
30
30
  Provides-Extra: docs
31
- Requires-Dist: sphinx ==7.3.7 ; extra == 'docs'
31
+ Requires-Dist: sphinx ==7.4.0 ; extra == 'docs'
32
32
  Requires-Dist: sphinx-rtd-theme ==2.0.0 ; extra == 'docs'
33
33
  Requires-Dist: readthedocs-sphinx-search ==0.3.2 ; extra == 'docs'
34
34
  Provides-Extra: test
35
35
  Requires-Dist: codespell ==2.3.0 ; extra == 'test'
36
- Requires-Dist: coverage ==7.5.3 ; extra == 'test'
37
- Requires-Dist: mypy ==1.10.0 ; extra == 'test'
38
- Requires-Dist: pyserial-asyncio-fast ==0.11 ; extra == 'test'
39
- Requires-Dist: pytest ==8.2.1 ; extra == 'test'
36
+ Requires-Dist: coverage ==7.6.0 ; extra == 'test'
37
+ Requires-Dist: mypy ==1.10.1 ; extra == 'test'
38
+ Requires-Dist: pyserial-asyncio-fast ==0.13 ; extra == 'test'
39
+ Requires-Dist: pytest ==8.2.2 ; extra == 'test'
40
40
  Requires-Dist: pytest-asyncio ==0.23.7 ; extra == 'test'
41
- Requires-Dist: ruff ==0.4.7 ; extra == 'test'
42
- Requires-Dist: tox ==4.15.0 ; extra == 'test'
41
+ Requires-Dist: ruff ==0.5.2 ; extra == 'test'
42
+ Requires-Dist: tox ==4.16.0 ; extra == 'test'
43
43
  Requires-Dist: types-pyserial ==3.5.0.20240527 ; extra == 'test'
44
44
 
45
45
  # PyPlumIO is a native ecoNET library for Plum ecoMAX controllers.
@@ -0,0 +1,60 @@
1
+ pyplumio/__init__.py,sha256=ditJTIOFGJDg60atHzOpiggdUrZHpSynno7MtpZUGVk,3299
2
+ pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
+ pyplumio/_version.py,sha256=7uyNQzq4TwYgrtNcQJPZodOcwZA-26alCK4Ab94ma2M,413
4
+ pyplumio/connection.py,sha256=QefTnJyMfFQV4f9TLRdkgP2aE9AmMjjfpFADQXgQqDE,6002
5
+ pyplumio/const.py,sha256=8rpiVbVb5R_6Rm6J2sgCnaVrkD-2Fzhd1RYMz0MBgwo,3915
6
+ pyplumio/exceptions.py,sha256=Wn-y5AJ5xfaBlHhTUVKB27_0Us8_OVHqh-sicnr9sYA,700
7
+ pyplumio/filters.py,sha256=IZkvrRAHdv6s3CplK73mHomRHpo3rnoyX2u26FVr9XU,11386
8
+ pyplumio/protocol.py,sha256=m2yPMXT2TcV-bv0jOQnwoanCpypYYh9fh7eZVOg7KTM,8108
9
+ pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pyplumio/stream.py,sha256=IVCQFKBtRafRgUkr93p_wN5mXZAD3Jw1d091dfEIK20,4479
11
+ pyplumio/utils.py,sha256=TnBzRopinyp92wruguijxcIYmaeyNVTFX0dygI5FCMU,823
12
+ pyplumio/devices/__init__.py,sha256=nbbi65b6bYzhXdUNQjSN4ViBVoKuxcuPIPflA_C_UME,6584
13
+ pyplumio/devices/ecomax.py,sha256=Oe0_i_EvcPI12zfcKVNwL_dDeK6ipusQArzypp3LBCo,17066
14
+ pyplumio/devices/ecoster.py,sha256=J4YtPmFmFwaq4LzYf28aMmB97cRAbMsVyUdBLGki42g,313
15
+ pyplumio/devices/mixer.py,sha256=j3ysCnRpbzAycBQYiRi5y1mgHRH0EidKpdIVClWs6rA,3313
16
+ pyplumio/devices/thermostat.py,sha256=kDTtoMcMAeSDf07vWnAOLh6EQuarh7HIz2W-5Eyg2j8,2649
17
+ pyplumio/frames/__init__.py,sha256=uMjLWY0rCbCTBfXafA_TSfLORYBT0wLyhHSZEePsRxw,7504
18
+ pyplumio/frames/messages.py,sha256=7vyOjcxGDnaRlyB4jPsCt00yCc3Axme8NN7uK922DS8,3622
19
+ pyplumio/frames/requests.py,sha256=Ra8xH5oKYhkEUtadN-9ZsJKkt5xZkz5O7edQVsDhNsM,7221
20
+ pyplumio/frames/responses.py,sha256=j4awA2-MfsoPdENC4Fvae4_Oa70rDhH19ebmEoAqhh8,6532
21
+ pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,31
22
+ pyplumio/helpers/data_types.py,sha256=5yxHCnsoKLw5kBM3s6SxwsuKs1C0yK2khyeSrrPXQsQ,8255
23
+ pyplumio/helpers/event_manager.py,sha256=PW1cczTVHx4VBGWtBbqT7Ay6G5vSQTb_WFCkJp4HZ8o,6195
24
+ pyplumio/helpers/factory.py,sha256=eiTkYUCernUn0VNDDdEN4IyjNPrXK8vnJESXyLaqFzE,1017
25
+ pyplumio/helpers/parameter.py,sha256=Av__MjrM4k7-AwJ71t6W1zyhMq8XdTeI2vh1-p_p1-4,9437
26
+ pyplumio/helpers/schedule.py,sha256=-IZJ-CU4PhFlsE586wTw--ovDrTo2Hs4JneCHhc0e-Y,5013
27
+ pyplumio/helpers/task_manager.py,sha256=HAd69yGTRL0zQsu-ywnbLu1UXiJzgHWuhYWA--vs4lQ,1181
28
+ pyplumio/helpers/timeout.py,sha256=XM58yaz93cNsxW7Ok6hfBw8i_92HdsGFQVBhpqbCZ70,770
29
+ pyplumio/helpers/uid.py,sha256=J7gN8i8LE0g6tfL66BJbwsQQqzBBxWx7giyvqaJh4BM,976
30
+ pyplumio/structures/__init__.py,sha256=EjK-5qJZ0F7lpP2b6epvTMg9cIBl4Kn91nqNkEcLwTc,1299
31
+ pyplumio/structures/alerts.py,sha256=wX58xWr1dJgZiQtEEMLRu8bcu6dTcc-aqEIY69gYGu0,3640
32
+ pyplumio/structures/boiler_load.py,sha256=p3mOzZUU-g7A2tG_yp8podEqpI81hlsOZmHELyPNRY8,838
33
+ pyplumio/structures/boiler_power.py,sha256=72qsvccg49FdRdXv2f2K5sGpjT7wAOLFjlIGWpO-DVg,901
34
+ pyplumio/structures/ecomax_parameters.py,sha256=oHKjqeX1fCCJpGMs385-2qJ3ondCvpJRpj5rS06ibf8,28076
35
+ pyplumio/structures/fan_power.py,sha256=Q5fv-7_2NVuLeQPIVIylvgN7M8-a9D8rRUE0QGjyS3w,871
36
+ pyplumio/structures/frame_versions.py,sha256=OMWU8tjnsrRWQsMSbmCJCmiKDwBmA75BcPZ6CqvKMLc,1566
37
+ pyplumio/structures/fuel_consumption.py,sha256=_p2dI4H67Eopn7IF0Gj77A8c_8lNKhhDDAtmugxLd4s,976
38
+ pyplumio/structures/fuel_level.py,sha256=mJpp1dnRD1wXi_6EyNX7TNXosjcr905rSHOnuZ5VD74,1069
39
+ pyplumio/structures/lambda_sensor.py,sha256=JNSCiBJoM8Uk3OGbmFIigaLOntQST5U_UrmCpaQBlM0,1595
40
+ pyplumio/structures/mixer_parameters.py,sha256=l4EQSEjmpjIULUWR-ulXiYWmBLTSfRoMvK8afKTVH6M,8763
41
+ pyplumio/structures/mixer_sensors.py,sha256=O91929Ts1YXFmKdPRc1r_BYDgrqkv5QVtE1nGzLpuAI,2260
42
+ pyplumio/structures/modules.py,sha256=ukju4TQmRRJfgl94QU4zytZLU5px8nw3sgfSLn9JysU,2520
43
+ pyplumio/structures/network_info.py,sha256=rxGoTdjlUmgEzR4BjOh9XQgEqKI6OSIbhOJ8tsXocts,4063
44
+ pyplumio/structures/output_flags.py,sha256=07N0kxlvR5WZAURuChk_BqSiXR8eaQrtI5qlkgCf4Yc,1345
45
+ pyplumio/structures/outputs.py,sha256=1xsJPkjN643-aFawqVoupGatUIUJfQG_g252n051Qi0,1916
46
+ pyplumio/structures/pending_alerts.py,sha256=Uq9WpB4MW9AhDkqmDhk-g0J0h4pVq0Q50z12dYEv6kY,739
47
+ pyplumio/structures/product_info.py,sha256=uiEN6DFQlzmBvQByTirFzXQShoex0YGdFS9WI-MAxPc,2405
48
+ pyplumio/structures/program_version.py,sha256=p3Hzn1igxGyZ99jJjPswNGCAAQdJ5_-sgZPIy-MGISI,2506
49
+ pyplumio/structures/regulator_data.py,sha256=Dun3RjfHHoV2W5RTSQcAimBL0Or3O957vYQj7Pbi7CM,2309
50
+ pyplumio/structures/regulator_data_schema.py,sha256=BMshEpiP-lwTgSkbTuow9KlxCwKwQXV0nFPcBpW0SJg,1505
51
+ pyplumio/structures/schedules.py,sha256=-koo05nLkpKuj1ZPiC1NB_21MAFn1FzQ6VLC0DboYeg,6346
52
+ pyplumio/structures/statuses.py,sha256=wkoynyMRr1VREwfBC6vU48kPA8ZQ83pcXuciy2xHJrk,1166
53
+ pyplumio/structures/temperatures.py,sha256=1CDzehNmbALz1Jyt_9gZNIk52q6Wv-xQXjijVDCVYec,2337
54
+ pyplumio/structures/thermostat_parameters.py,sha256=1QkgOnDndBMWpGa8GEJLdewLkdF8UqF03yhoVzYqYJE,7796
55
+ pyplumio/structures/thermostat_sensors.py,sha256=ZmjWgYtTZ5M8Lnz_Q5N4JD8G3MvEmByPFjYsy6XZOmo,3177
56
+ PyPlumIO-0.5.22.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
+ PyPlumIO-0.5.22.dist-info/METADATA,sha256=3u07pneJ8FCqq5Mj1WfLJQAd_iBqYRvlwC_I1vby3hI,5415
58
+ PyPlumIO-0.5.22.dist-info/WHEEL,sha256=rWxmBtp7hEUqVLOnTaDOPpR-cZpCDkzhhcBce-Zyd5k,91
59
+ PyPlumIO-0.5.22.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
+ PyPlumIO-0.5.22.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.43.0)
2
+ Generator: setuptools (71.0.4)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
pyplumio/__init__.py CHANGED
@@ -10,7 +10,7 @@ from pyplumio.exceptions import (
10
10
  ChecksumError,
11
11
  ConnectionFailedError,
12
12
  FrameDataError,
13
- FrameError,
13
+ ProtocolError,
14
14
  PyPlumIOError,
15
15
  ReadError,
16
16
  UnknownDeviceError,
@@ -97,8 +97,8 @@ __all__ = [
97
97
  "EthernetParameters",
98
98
  "Frame",
99
99
  "FrameDataError",
100
- "FrameError",
101
100
  "Protocol",
101
+ "ProtocolError",
102
102
  "PyPlumIOError",
103
103
  "ReadError",
104
104
  "SerialConnection",
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.20'
16
- __version_tuple__ = version_tuple = (0, 5, 20)
15
+ __version__ = version = '0.5.22'
16
+ __version_tuple__ = version_tuple = (0, 5, 22)
pyplumio/connection.py CHANGED
@@ -131,11 +131,7 @@ class TcpConnection(Connection):
131
131
  **kwargs: Any,
132
132
  ) -> None:
133
133
  """Initialize a new TCP connection."""
134
- super().__init__(
135
- protocol,
136
- reconnect_on_failure,
137
- **kwargs,
138
- )
134
+ super().__init__(protocol, reconnect_on_failure, **kwargs)
139
135
  self.host = host
140
136
  self.port = port
141
137
 
@@ -171,11 +167,7 @@ class SerialConnection(Connection):
171
167
  **kwargs: Any,
172
168
  ) -> None:
173
169
  """Initialize a new serial connection."""
174
- super().__init__(
175
- protocol,
176
- reconnect_on_failure,
177
- **kwargs,
178
- )
170
+ super().__init__(protocol, reconnect_on_failure, **kwargs)
179
171
  self.device = device
180
172
  self.baudrate = baudrate
181
173
 
@@ -13,8 +13,7 @@ from pyplumio.exceptions import UnknownDeviceError
13
13
  from pyplumio.frames import DataFrameDescription, Frame, Request
14
14
  from pyplumio.helpers.event_manager import EventManager
15
15
  from pyplumio.helpers.factory import create_instance
16
- from pyplumio.helpers.parameter import SET_RETRIES, Parameter
17
- from pyplumio.helpers.typing import ParameterValueType
16
+ from pyplumio.helpers.parameter import SET_RETRIES, Parameter, ParameterValueType
18
17
  from pyplumio.structures.network_info import NetworkInfo
19
18
  from pyplumio.utils import to_camelcase
20
19
 
@@ -45,9 +44,9 @@ def get_device_handler(device_type: int) -> str:
45
44
  class Device(ABC, EventManager):
46
45
  """Represents a device."""
47
46
 
48
- queue: asyncio.Queue
47
+ queue: asyncio.Queue[Frame]
49
48
 
50
- def __init__(self, queue: asyncio.Queue):
49
+ def __init__(self, queue: asyncio.Queue[Frame]):
51
50
  """Initialize a new device."""
52
51
  super().__init__()
53
52
  self.queue = queue
@@ -124,7 +123,7 @@ class AddressableDevice(Device, ABC):
124
123
  _network: NetworkInfo
125
124
  _setup_frames: Iterable[DataFrameDescription]
126
125
 
127
- def __init__(self, queue: asyncio.Queue, network: NetworkInfo):
126
+ def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo):
128
127
  """Initialize a new addressable device."""
129
128
  super().__init__(queue)
130
129
  self._network = network
@@ -143,19 +142,20 @@ class AddressableDevice(Device, ABC):
143
142
  async def async_setup(self) -> bool:
144
143
  """Set up addressable device."""
145
144
  results = await asyncio.gather(
146
- *{
145
+ *(
147
146
  self.request(description.provides, description.frame_type)
148
147
  for description in self._setup_frames
149
- },
148
+ ),
150
149
  return_exceptions=True,
151
150
  )
152
151
 
153
152
  errors = [
154
- result.args[1] for result in results if isinstance(result, ValueError)
153
+ result.args[1] for result in results if isinstance(result, BaseException)
155
154
  ]
156
155
 
157
- await self.dispatch(ATTR_FRAME_ERRORS, errors)
158
- await self.dispatch(ATTR_LOADED, True)
156
+ await asyncio.gather(
157
+ self.dispatch(ATTR_FRAME_ERRORS, errors), self.dispatch(ATTR_LOADED, True)
158
+ )
159
159
  return True
160
160
 
161
161
  async def request(
@@ -178,9 +178,7 @@ class AddressableDevice(Device, ABC):
178
178
  @classmethod
179
179
  async def create(cls, device_type: int, **kwargs: Any) -> AddressableDevice:
180
180
  """Create a device handler object."""
181
- return await create_instance(
182
- get_device_handler(device_type), cls=AddressableDevice, **kwargs
183
- )
181
+ return await create_instance(get_device_handler(device_type), cls=cls, **kwargs)
184
182
 
185
183
 
186
184
  class SubDevice(Device, ABC):
@@ -189,7 +187,9 @@ class SubDevice(Device, ABC):
189
187
  parent: AddressableDevice
190
188
  index: int
191
189
 
192
- def __init__(self, queue: asyncio.Queue, parent: AddressableDevice, index: int = 0):
190
+ def __init__(
191
+ self, queue: asyncio.Queue[Frame], parent: AddressableDevice, index: int = 0
192
+ ):
193
193
  """Initialize a new sub-device."""
194
194
  super().__init__(queue)
195
195
  self.parent = parent
@@ -3,8 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import Generator, Iterable, Sequence
7
- from contextlib import suppress
6
+ from collections.abc import Coroutine, Generator, Iterable, Sequence
8
7
  import logging
9
8
  import time
10
9
  from typing import Any, ClassVar, Final
@@ -65,27 +64,38 @@ ATTR_FUEL_BURNED: Final = "fuel_burned"
65
64
  MAX_TIME_SINCE_LAST_FUEL_UPDATE_NS: Final = 300 * 1000000000
66
65
 
67
66
  SETUP_FRAME_TYPES: tuple[DataFrameDescription, ...] = (
68
- DataFrameDescription(frame_type=FrameType.REQUEST_UID, provides=ATTR_PRODUCT),
69
67
  DataFrameDescription(
70
- frame_type=FrameType.REQUEST_REGULATOR_DATA_SCHEMA, provides=ATTR_REGDATA_SCHEMA
68
+ frame_type=FrameType.REQUEST_UID,
69
+ provides=ATTR_PRODUCT,
71
70
  ),
72
71
  DataFrameDescription(
73
- frame_type=FrameType.REQUEST_ECOMAX_PARAMETERS, provides=ATTR_ECOMAX_PARAMETERS
72
+ frame_type=FrameType.REQUEST_REGULATOR_DATA_SCHEMA,
73
+ provides=ATTR_REGDATA_SCHEMA,
74
74
  ),
75
75
  DataFrameDescription(
76
- frame_type=FrameType.REQUEST_ALERTS, provides=ATTR_TOTAL_ALERTS
76
+ frame_type=FrameType.REQUEST_ECOMAX_PARAMETERS,
77
+ provides=ATTR_ECOMAX_PARAMETERS,
77
78
  ),
78
79
  DataFrameDescription(
79
- frame_type=FrameType.REQUEST_SCHEDULES, provides=ATTR_SCHEDULES
80
+ frame_type=FrameType.REQUEST_ALERTS,
81
+ provides=ATTR_TOTAL_ALERTS,
80
82
  ),
81
83
  DataFrameDescription(
82
- frame_type=FrameType.REQUEST_MIXER_PARAMETERS, provides=ATTR_MIXER_PARAMETERS
84
+ frame_type=FrameType.REQUEST_SCHEDULES,
85
+ provides=ATTR_SCHEDULES,
86
+ ),
87
+ DataFrameDescription(
88
+ frame_type=FrameType.REQUEST_MIXER_PARAMETERS,
89
+ provides=ATTR_MIXER_PARAMETERS,
83
90
  ),
84
91
  DataFrameDescription(
85
92
  frame_type=FrameType.REQUEST_THERMOSTAT_PARAMETERS,
86
93
  provides=ATTR_THERMOSTAT_PARAMETERS,
87
94
  ),
88
- DataFrameDescription(frame_type=FrameType.REQUEST_PASSWORD, provides=ATTR_PASSWORD),
95
+ DataFrameDescription(
96
+ frame_type=FrameType.REQUEST_PASSWORD,
97
+ provides=ATTR_PASSWORD,
98
+ ),
89
99
  )
90
100
 
91
101
  _LOGGER = logging.getLogger(__name__)
@@ -95,11 +105,11 @@ class EcoMAX(AddressableDevice):
95
105
  """Represents an ecoMAX controller."""
96
106
 
97
107
  address: ClassVar[int] = DeviceType.ECOMAX
98
- _setup_frames: Iterable[DataFrameDescription] = SETUP_FRAME_TYPES
108
+ _setup_frames: tuple[DataFrameDescription, ...] = SETUP_FRAME_TYPES
99
109
  _frame_versions: dict[int, int]
100
110
  _fuel_burned_timestamp_ns: int
101
111
 
102
- def __init__(self, queue: asyncio.Queue, network: NetworkInfo):
112
+ def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo):
103
113
  """Initialize a new ecoMAX controller."""
104
114
  super().__init__(queue, network)
105
115
  self._frame_versions = {}
@@ -124,11 +134,10 @@ class EcoMAX(AddressableDevice):
124
134
 
125
135
  def handle_frame(self, frame: Frame) -> None:
126
136
  """Handle frame received from the ecoMAX device."""
127
- if isinstance(frame, Request) and frame.frame_type in (
128
- FrameType.REQUEST_CHECK_DEVICE,
129
- FrameType.REQUEST_PROGRAM_VERSION,
137
+ if isinstance(frame, Request) and (
138
+ response := frame.response(data={ATTR_NETWORK: self._network})
130
139
  ):
131
- self.queue.put_nowait(frame.response(data={ATTR_NETWORK: self._network}))
140
+ self.queue.put_nowait(response)
132
141
 
133
142
  super().handle_frame(frame)
134
143
 
@@ -149,12 +158,9 @@ class EcoMAX(AddressableDevice):
149
158
  For each index, return or create an instance of the mixer class.
150
159
  Once done, dispatch the 'mixers' event without waiting.
151
160
  """
152
- mixers = self.data.setdefault(ATTR_MIXERS, {})
161
+ mixers: dict[int, Mixer] = self.data.setdefault(ATTR_MIXERS, {})
153
162
  for index in indexes:
154
- if index not in mixers:
155
- mixers[index] = Mixer(self.queue, parent=self, index=index)
156
-
157
- yield mixers[index]
163
+ yield mixers.setdefault(index, Mixer(self.queue, parent=self, index=index))
158
164
 
159
165
  return self.dispatch_nowait(ATTR_MIXERS, mixers)
160
166
 
@@ -165,12 +171,11 @@ class EcoMAX(AddressableDevice):
165
171
  class. Once done, dispatch the 'thermostats' event without
166
172
  waiting.
167
173
  """
168
- thermostats = self.data.setdefault(ATTR_THERMOSTATS, {})
174
+ thermostats: dict[int, Thermostat] = self.data.setdefault(ATTR_THERMOSTATS, {})
169
175
  for index in indexes:
170
- if index not in thermostats:
171
- thermostats[index] = Thermostat(self.queue, parent=self, index=index)
172
-
173
- yield thermostats[index]
176
+ yield thermostats.setdefault(
177
+ index, Thermostat(self.queue, parent=self, index=index)
178
+ )
174
179
 
175
180
  return self.dispatch_nowait(ATTR_THERMOSTATS, thermostats)
176
181
 
@@ -183,41 +188,42 @@ class EcoMAX(AddressableDevice):
183
188
  and value.
184
189
  """
185
190
  product: ProductInfo = await self.get(ATTR_PRODUCT)
186
- for index, values in parameters:
187
- try:
188
- description = ECOMAX_PARAMETERS[product.type][index]
189
- except IndexError:
190
- _LOGGER.warning(
191
- (
192
- "Encountered unknown ecoMAX parameter (%i): %s. "
193
- "Your device isn't fully compatible with this software and "
194
- "may not work properly. "
195
- "Please visit the issue tracker and open a feature "
196
- "request to support %s"
191
+
192
+ def _ecomax_parameter_events() -> Generator[Coroutine, Any, None]:
193
+ """Get dispatch calls for ecoMAX parameter events."""
194
+ for index, values in parameters:
195
+ try:
196
+ description = ECOMAX_PARAMETERS[product.type][index]
197
+ except IndexError:
198
+ _LOGGER.warning(
199
+ (
200
+ "Encountered unknown ecoMAX parameter (%i): %s. "
201
+ "Your device isn't fully compatible with this software and "
202
+ "may not work properly. "
203
+ "Please visit the issue tracker and open a feature "
204
+ "request to support %s"
205
+ ),
206
+ index,
207
+ values,
208
+ product.model,
209
+ )
210
+
211
+ handler = (
212
+ EcomaxBinaryParameter
213
+ if isinstance(description, EcomaxBinaryParameterDescription)
214
+ else EcomaxParameter
215
+ )
216
+ yield self.dispatch(
217
+ description.name,
218
+ handler.create_or_update(
219
+ device=self,
220
+ description=description,
221
+ values=values,
222
+ index=index,
197
223
  ),
198
- index,
199
- values,
200
- product.model,
201
224
  )
202
- return False
203
-
204
- name = description.name
205
- if name in self.data:
206
- parameter: EcomaxParameter = self.data[name]
207
- parameter.values = values
208
- await self.dispatch(name, parameter)
209
- continue
210
-
211
- cls = (
212
- EcomaxBinaryParameter
213
- if isinstance(description, EcomaxBinaryParameterDescription)
214
- else EcomaxParameter
215
- )
216
- await self.dispatch(
217
- name,
218
- cls(device=self, values=values, description=description, index=index),
219
- )
220
225
 
226
+ await asyncio.gather(*_ecomax_parameter_events())
221
227
  return True
222
228
 
223
229
  async def _update_frame_versions(self, versions: dict[int, int]) -> None:
@@ -237,18 +243,15 @@ class EcoMAX(AddressableDevice):
237
243
  """Calculate fuel burned since last sensor's data message."""
238
244
  current_timestamp_ns = time.perf_counter_ns()
239
245
  time_passed_ns = current_timestamp_ns - self._fuel_burned_timestamp_ns
246
+ self._fuel_burned_timestamp_ns = current_timestamp_ns
240
247
  if time_passed_ns >= MAX_TIME_SINCE_LAST_FUEL_UPDATE_NS:
241
248
  _LOGGER.warning(
242
249
  "Skipping outdated fuel consumption data, was %i seconds old",
243
250
  time_passed_ns / 1000000000,
244
251
  )
245
252
  else:
246
- await self.dispatch(
247
- ATTR_FUEL_BURNED,
248
- fuel_consumption * time_passed_ns / (3600 * 1000000000),
249
- )
250
-
251
- self._fuel_burned_timestamp_ns = current_timestamp_ns
253
+ fuel_burned = fuel_consumption * time_passed_ns / (3600 * 1000000000)
254
+ await self.dispatch(ATTR_FUEL_BURNED, fuel_burned)
252
255
 
253
256
  async def _handle_mixer_parameters(
254
257
  self,
@@ -263,12 +266,18 @@ class EcoMAX(AddressableDevice):
263
266
  if not parameters:
264
267
  return False
265
268
 
266
- for mixer in self._mixers(parameters.keys()):
267
- await mixer.dispatch(ATTR_MIXER_PARAMETERS, parameters[mixer.index])
269
+ await asyncio.gather(
270
+ *(
271
+ mixer.dispatch(ATTR_MIXER_PARAMETERS, parameters[mixer.index])
272
+ for mixer in self._mixers(indexes=parameters.keys())
273
+ )
274
+ )
268
275
 
269
276
  return True
270
277
 
271
- async def _handle_mixer_sensors(self, sensors: dict[int, dict[str, Any]]) -> bool:
278
+ async def _handle_mixer_sensors(
279
+ self, sensors: dict[int, dict[str, Any]] | None
280
+ ) -> bool:
272
281
  """Handle mixer sensors.
273
282
 
274
283
  For each sensor dispatch an event with the
@@ -278,14 +287,18 @@ class EcoMAX(AddressableDevice):
278
287
  if not sensors:
279
288
  return False
280
289
 
281
- for mixer in self._mixers(sensors.keys()):
282
- await mixer.dispatch(ATTR_MIXER_SENSORS, sensors[mixer.index])
290
+ await asyncio.gather(
291
+ *(
292
+ mixer.dispatch(ATTR_MIXER_SENSORS, sensors[mixer.index])
293
+ for mixer in self._mixers(indexes=sensors.keys())
294
+ )
295
+ )
283
296
 
284
297
  return True
285
298
 
286
299
  async def _add_schedules(
287
300
  self, schedules: list[tuple[int, list[list[bool]]]]
288
- ) -> dict[str, Any]:
301
+ ) -> dict[str, Schedule]:
289
302
  """Add schedules to the dataset."""
290
303
  return {
291
304
  SCHEDULES[index]: Schedule(
@@ -306,25 +319,27 @@ class EcoMAX(AddressableDevice):
306
319
  self, parameters: Sequence[tuple[int, ParameterValues]]
307
320
  ) -> bool:
308
321
  """Add schedule parameters to the dataset."""
309
- for index, values in parameters:
310
- description = SCHEDULE_PARAMETERS[index]
311
- name = description.name
312
- if name in self.data:
313
- parameter: ScheduleParameter = self.data[name]
314
- parameter.values = values
315
- await self.dispatch(name, parameter)
316
- continue
317
-
318
- cls = (
319
- ScheduleBinaryParameter
320
- if isinstance(description, ScheduleBinaryParameterDescription)
321
- else ScheduleParameter
322
- )
323
- await self.dispatch(
324
- name,
325
- cls(device=self, values=values, description=description, index=index),
326
- )
327
322
 
323
+ def _schedule_parameter_events() -> Generator[Coroutine, Any, None]:
324
+ """Get dispatch calls for schedule parameter events."""
325
+ for index, values in parameters:
326
+ description = SCHEDULE_PARAMETERS[index]
327
+ handler = (
328
+ ScheduleBinaryParameter
329
+ if isinstance(description, ScheduleBinaryParameterDescription)
330
+ else ScheduleParameter
331
+ )
332
+ yield self.dispatch(
333
+ description.name,
334
+ handler.create_or_update(
335
+ device=self,
336
+ description=description,
337
+ values=values,
338
+ index=index,
339
+ ),
340
+ )
341
+
342
+ await asyncio.gather(*_schedule_parameter_events())
328
343
  return True
329
344
 
330
345
  async def _handle_ecomax_sensors(self, sensors: dict[str, Any]) -> bool:
@@ -333,28 +348,21 @@ class EcoMAX(AddressableDevice):
333
348
  For each sensor dispatch an event with the sensor's name and
334
349
  value.
335
350
  """
336
- for name, value in sensors.items():
337
- await self.dispatch(name, value)
338
-
351
+ await asyncio.gather(
352
+ *(self.dispatch(name, value) for name, value in sensors.items())
353
+ )
339
354
  return True
340
355
 
341
356
  async def _add_ecomax_control_parameter(self, mode: DeviceState) -> None:
342
357
  """Create ecoMAX control parameter instance and dispatch an event."""
343
- description = ECOMAX_CONTROL_PARAMETER
344
- name = description.name
345
- values = ParameterValues(
346
- value=int(mode != DeviceState.OFF), min_value=0, max_value=1
347
- )
348
-
349
- if name in self.data:
350
- parameter: EcomaxBinaryParameter = self.data[name]
351
- parameter.values = values
352
- return await self.dispatch(name, parameter)
353
-
354
358
  await self.dispatch(
355
- name,
356
- EcomaxBinaryParameter(
357
- device=self, description=ECOMAX_CONTROL_PARAMETER, values=values
359
+ ECOMAX_CONTROL_PARAMETER.name,
360
+ EcomaxBinaryParameter.create_or_update(
361
+ description=ECOMAX_CONTROL_PARAMETER,
362
+ device=self,
363
+ values=ParameterValues(
364
+ value=int(mode != DeviceState.OFF), min_value=0, max_value=1
365
+ ),
358
366
  ),
359
367
  )
360
368
 
@@ -371,26 +379,29 @@ class EcoMAX(AddressableDevice):
371
379
  if not parameters:
372
380
  return False
373
381
 
374
- for thermostat in self._thermostats(parameters.keys()):
375
- await thermostat.dispatch(
376
- ATTR_THERMOSTAT_PARAMETERS, parameters[thermostat.index]
382
+ await asyncio.gather(
383
+ *(
384
+ thermostat.dispatch(
385
+ ATTR_THERMOSTAT_PARAMETERS, parameters[thermostat.index]
386
+ )
387
+ for thermostat in self._thermostats(indexes=parameters.keys())
377
388
  )
378
-
389
+ )
379
390
  return True
380
391
 
381
392
  async def _add_thermostat_profile_parameter(
382
393
  self, values: ParameterValues | None
383
394
  ) -> EcomaxParameter | None:
384
395
  """Add thermostat profile parameter to the dataset."""
385
- if values is not None:
386
- return EcomaxParameter(
387
- device=self, description=THERMOSTAT_PROFILE_PARAMETER, values=values
388
- )
396
+ if not values:
397
+ return None
389
398
 
390
- return None
399
+ return EcomaxParameter(
400
+ device=self, description=THERMOSTAT_PROFILE_PARAMETER, values=values
401
+ )
391
402
 
392
403
  async def _handle_thermostat_sensors(
393
- self, sensors: dict[int, dict[str, Any]]
404
+ self, sensors: dict[int, dict[str, Any]] | None
394
405
  ) -> bool:
395
406
  """Handle thermostat sensors.
396
407
 
@@ -401,10 +412,13 @@ class EcoMAX(AddressableDevice):
401
412
  if not sensors:
402
413
  return False
403
414
 
404
- for thermostat in self._thermostats(sensors.keys()):
405
- await thermostat.dispatch(
406
- ATTR_THERMOSTAT_SENSORS, sensors[thermostat.index]
407
- )
415
+ await asyncio.gather(
416
+ *(
417
+ thermostat.dispatch(ATTR_THERMOSTAT_SENSORS, sensors[thermostat.index])
418
+ for thermostat in self._thermostats(indexes=sensors.keys())
419
+ ),
420
+ return_exceptions=True,
421
+ )
408
422
 
409
423
  return True
410
424
 
@@ -436,12 +450,8 @@ class EcoMAX(AddressableDevice):
436
450
 
437
451
  async def shutdown(self) -> None:
438
452
  """Shutdown tasks for the ecoMAX controller and sub-devices."""
439
- mixers = self.get_nowait(ATTR_MIXERS, {})
440
- thermostats = self.get_nowait(ATTR_THERMOSTATS, {})
441
- for subdevice in (mixers | thermostats).values():
442
- await subdevice.shutdown()
443
-
444
- with suppress(AttributeError):
445
- await self.regdata.shutdown()
446
-
453
+ mixers: dict[str, Mixer] = self.get_nowait(ATTR_MIXERS, {})
454
+ thermostats: dict[str, Thermostat] = self.get_nowait(ATTR_THERMOSTATS, {})
455
+ devices = (mixers | thermostats).values()
456
+ await asyncio.gather(*(device.shutdown() for device in devices))
447
457
  await super().shutdown()