python-izone 1.2.9__py3-none-any.whl → 1.2.10__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.
- pizone/__init__.py +13 -13
- pizone/controller.py +78 -59
- pizone/discovery.py +191 -155
- pizone/power.py +12 -13
- pizone/version.py +23 -4
- pizone/zone.py +12 -12
- {python_izone-1.2.9.dist-info → python_izone-1.2.10.dist-info}/METADATA +14 -14
- python_izone-1.2.10.dist-info/RECORD +11 -0
- {python_izone-1.2.9.dist-info → python_izone-1.2.10.dist-info}/WHEEL +1 -1
- python_izone-1.2.9.dist-info/RECORD +0 -11
- {python_izone-1.2.9.dist-info → python_izone-1.2.10.dist-info/licenses}/licence.txt +0 -0
- {python_izone-1.2.9.dist-info → python_izone-1.2.10.dist-info}/top_level.txt +0 -0
pizone/__init__.py
CHANGED
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
Interaction mostly through the Controller and Zone classes.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from .power import Power, PowerGroup, PowerDevice, PowerChannel, BatteryLevel
|
|
7
|
-
from .zone import Zone
|
|
8
6
|
from .controller import Controller
|
|
9
7
|
from .discovery import DiscoveryService, Listener, discovery
|
|
8
|
+
from .power import BatteryLevel, Power, PowerChannel, PowerDevice, PowerGroup
|
|
9
|
+
from .zone import Zone
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
Controller,
|
|
13
|
-
Zone,
|
|
14
|
-
DiscoveryService,
|
|
15
|
-
Listener,
|
|
16
|
-
discovery,
|
|
17
|
-
Power,
|
|
18
|
-
PowerGroup,
|
|
19
|
-
PowerDevice,
|
|
20
|
-
PowerChannel,
|
|
21
|
-
BatteryLevel,
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Controller",
|
|
13
|
+
"Zone",
|
|
14
|
+
"DiscoveryService",
|
|
15
|
+
"Listener",
|
|
16
|
+
"discovery",
|
|
17
|
+
"Power",
|
|
18
|
+
"PowerGroup",
|
|
19
|
+
"PowerDevice",
|
|
20
|
+
"PowerChannel",
|
|
21
|
+
"BatteryLevel",
|
|
22
22
|
]
|
pizone/controller.py
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
"""Controller module"""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import asyncio
|
|
4
6
|
import json
|
|
5
7
|
import logging
|
|
6
8
|
from asyncio import Condition, Lock
|
|
7
9
|
from enum import Enum
|
|
8
10
|
from json.decoder import JSONDecodeError
|
|
9
|
-
from typing import Any, Dict,
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Dict, Union, cast
|
|
10
12
|
|
|
11
13
|
import aiohttp
|
|
12
|
-
from async_timeout import timeout
|
|
13
14
|
|
|
14
15
|
from .power import Power
|
|
15
16
|
from .zone import Zone
|
|
16
17
|
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .discovery import DiscoveryService, Listener
|
|
20
|
+
|
|
17
21
|
_LOG = logging.getLogger("pizone.controller")
|
|
18
22
|
|
|
19
23
|
|
|
@@ -58,10 +62,17 @@ class Controller:
|
|
|
58
62
|
"3-speed": [Fan.LOW, Fan.MED, Fan.HIGH, Fan.AUTO],
|
|
59
63
|
"2-speed": [Fan.LOW, Fan.HIGH, Fan.AUTO],
|
|
60
64
|
"var-speed": [Fan.LOW, Fan.MED, Fan.HIGH, Fan.AUTO],
|
|
61
|
-
}
|
|
65
|
+
}
|
|
62
66
|
|
|
63
67
|
def __init__(
|
|
64
|
-
self,
|
|
68
|
+
self,
|
|
69
|
+
discovery_service: DiscoveryService,
|
|
70
|
+
event_coordinator: Listener,
|
|
71
|
+
*,
|
|
72
|
+
device_uid: str,
|
|
73
|
+
device_ip: str,
|
|
74
|
+
is_v2: bool,
|
|
75
|
+
is_ipower: bool,
|
|
65
76
|
) -> None:
|
|
66
77
|
"""Create a controller interface.
|
|
67
78
|
|
|
@@ -83,25 +94,26 @@ class Controller:
|
|
|
83
94
|
device discovered at the given IP address or UId
|
|
84
95
|
"""
|
|
85
96
|
self._ip = device_ip
|
|
86
|
-
self.
|
|
97
|
+
self._discovery_service = discovery_service
|
|
98
|
+
self._event_coordinator = event_coordinator
|
|
87
99
|
self._device_uid = device_uid
|
|
88
100
|
self._is_v2 = is_v2
|
|
89
101
|
self._is_ipower = is_ipower
|
|
90
102
|
|
|
91
|
-
self.zones
|
|
92
|
-
self.fan_modes
|
|
93
|
-
self._system_settings = {}
|
|
94
|
-
self._power
|
|
103
|
+
self.zones: list[Zone] = []
|
|
104
|
+
self.fan_modes: list[Controller.Fan] = []
|
|
105
|
+
self._system_settings: Controller.ControllerData = {}
|
|
106
|
+
self._power: Power | None = None
|
|
95
107
|
|
|
96
|
-
self.
|
|
97
|
-
self._fail_exception = None
|
|
108
|
+
self._initialized: bool = False
|
|
109
|
+
self._fail_exception: Exception | None = None
|
|
98
110
|
|
|
99
111
|
self._sending_lock = Lock()
|
|
100
112
|
self._scan_condition = Condition()
|
|
101
113
|
|
|
102
114
|
async def _initialize(self) -> None:
|
|
103
115
|
"""Initialize the controller, does not complete until the system is
|
|
104
|
-
|
|
116
|
+
initialized."""
|
|
105
117
|
await self._refresh_system(notify=False)
|
|
106
118
|
|
|
107
119
|
self.fan_modes = Controller._VALID_FAN_MODES[
|
|
@@ -120,13 +132,13 @@ class Controller:
|
|
|
120
132
|
else:
|
|
121
133
|
self._power = None
|
|
122
134
|
|
|
123
|
-
self.
|
|
124
|
-
self.
|
|
135
|
+
self._initialized = True
|
|
136
|
+
self._discovery_service.create_task(self._poll_loop())
|
|
125
137
|
|
|
126
138
|
async def _poll_loop(self) -> None:
|
|
127
139
|
while True:
|
|
128
140
|
try:
|
|
129
|
-
async with timeout(Controller.REFRESH_INTERVAL):
|
|
141
|
+
async with asyncio.timeout(Controller.REFRESH_INTERVAL):
|
|
130
142
|
async with self._scan_condition:
|
|
131
143
|
await self._scan_condition.wait()
|
|
132
144
|
# triggered rescan, short delay
|
|
@@ -134,7 +146,7 @@ class Controller:
|
|
|
134
146
|
except asyncio.TimeoutError:
|
|
135
147
|
pass
|
|
136
148
|
|
|
137
|
-
if self.
|
|
149
|
+
if self._discovery_service.is_closed:
|
|
138
150
|
return
|
|
139
151
|
|
|
140
152
|
# pylint: disable=broad-except
|
|
@@ -142,7 +154,7 @@ class Controller:
|
|
|
142
154
|
_LOG.debug("Polling unit %s.", self._device_uid)
|
|
143
155
|
await self._refresh_all()
|
|
144
156
|
except ConnectionError:
|
|
145
|
-
_LOG.debug("Poll failed due to
|
|
157
|
+
_LOG.debug("Poll failed due to exception.", exc_info=True)
|
|
146
158
|
except Exception:
|
|
147
159
|
_LOG.error("Unexpected exception", exc_info=True)
|
|
148
160
|
|
|
@@ -157,7 +169,7 @@ class Controller:
|
|
|
157
169
|
return self._fail_exception is None
|
|
158
170
|
|
|
159
171
|
@property
|
|
160
|
-
def power(self) ->
|
|
172
|
+
def power(self) -> Power | None:
|
|
161
173
|
"""Power info"""
|
|
162
174
|
return self._power
|
|
163
175
|
|
|
@@ -177,9 +189,9 @@ class Controller:
|
|
|
177
189
|
return self._is_v2
|
|
178
190
|
|
|
179
191
|
@property
|
|
180
|
-
def discovery(self):
|
|
192
|
+
def discovery(self) -> Any:
|
|
181
193
|
"""The discovery service"""
|
|
182
|
-
return self.
|
|
194
|
+
return self._discovery_service
|
|
183
195
|
|
|
184
196
|
@property
|
|
185
197
|
def is_on(self) -> bool:
|
|
@@ -197,7 +209,7 @@ class Controller:
|
|
|
197
209
|
return self.Mode.FREE_AIR
|
|
198
210
|
return self.Mode(self._get_system_state("SysMode"))
|
|
199
211
|
|
|
200
|
-
async def set_mode(self, value: Mode):
|
|
212
|
+
async def set_mode(self, value: Mode) -> None:
|
|
201
213
|
"""Set system mode, cooling, heating, etc."""
|
|
202
214
|
if value == Controller.Mode.FREE_AIR:
|
|
203
215
|
if self.free_air:
|
|
@@ -234,7 +246,7 @@ class Controller:
|
|
|
234
246
|
"""Current setting for the sleep timer."""
|
|
235
247
|
return int(self._get_system_state("SleepTimer"))
|
|
236
248
|
|
|
237
|
-
async def set_sleep_timer(self, value: int):
|
|
249
|
+
async def set_sleep_timer(self, value: int) -> None:
|
|
238
250
|
"""The sleep timer.
|
|
239
251
|
Valid settings are 0, 30, 60, 90, 120
|
|
240
252
|
Raises:
|
|
@@ -268,19 +280,19 @@ class Controller:
|
|
|
268
280
|
await self._set_system_state("FreeAir", "FreeAir", "on" if value else "off")
|
|
269
281
|
|
|
270
282
|
@property
|
|
271
|
-
def temp_supply(self) ->
|
|
283
|
+
def temp_supply(self) -> float | None:
|
|
272
284
|
"""Current supply, or in duct, air temperature."""
|
|
273
285
|
return float(self._get_system_state("Supply")) or None
|
|
274
286
|
|
|
275
287
|
@property
|
|
276
|
-
def temp_setpoint(self) ->
|
|
288
|
+
def temp_setpoint(self) -> float | None:
|
|
277
289
|
"""AC unit setpoint temperature.
|
|
278
290
|
This is the unit target temp with with rasMode == RAS,
|
|
279
291
|
or with rasMode == master and ctrlZone == 13.
|
|
280
292
|
"""
|
|
281
293
|
return float(self._get_system_state("Setpoint")) or None
|
|
282
294
|
|
|
283
|
-
async def set_temp_setpoint(self, value: float):
|
|
295
|
+
async def set_temp_setpoint(self, value: float) -> None:
|
|
284
296
|
"""AC unit setpoint temperature.
|
|
285
297
|
This is the unit target temp with with rasMode == RAS,
|
|
286
298
|
or with rasMode == master and ctrlZone == 13.
|
|
@@ -298,7 +310,7 @@ class Controller:
|
|
|
298
310
|
await self._set_system_state("Setpoint", "UnitSetpoint", value, str(value))
|
|
299
311
|
|
|
300
312
|
@property
|
|
301
|
-
def temp_return(self) ->
|
|
313
|
+
def temp_return(self) -> float | None:
|
|
302
314
|
"""The return, or room, air temperature"""
|
|
303
315
|
return float(self._get_system_state("Temp")) or None
|
|
304
316
|
|
|
@@ -314,7 +326,7 @@ class Controller:
|
|
|
314
326
|
|
|
315
327
|
@property
|
|
316
328
|
def temp_max(self) -> float:
|
|
317
|
-
"""The value for the eco lock
|
|
329
|
+
"""The value for the eco lock maximum, or 30 if eco lock not set"""
|
|
318
330
|
return float(self._get_system_state("EcoMax")) if self.eco_lock else 30.0
|
|
319
331
|
|
|
320
332
|
@property
|
|
@@ -368,17 +380,15 @@ class Controller:
|
|
|
368
380
|
|
|
369
381
|
async def _refresh_system(self, notify: bool = True) -> None:
|
|
370
382
|
"""Refresh the system settings."""
|
|
371
|
-
values = await self._get_resource(
|
|
372
|
-
"SystemSettings"
|
|
373
|
-
) # type: Controller.ControllerData # noqa
|
|
383
|
+
values: Controller.ControllerData = await self._get_resource("SystemSettings")
|
|
374
384
|
if self._device_uid != values["AirStreamDeviceUId"]:
|
|
375
|
-
_LOG.error("_refresh_system called with
|
|
385
|
+
_LOG.error("_refresh_system called with non-matching device ID")
|
|
376
386
|
return
|
|
377
387
|
|
|
378
388
|
self._system_settings = values
|
|
379
389
|
|
|
380
390
|
if notify:
|
|
381
|
-
self.
|
|
391
|
+
self._event_coordinator.controller_update(self)
|
|
382
392
|
|
|
383
393
|
async def _refresh_power(self, notify: bool = True) -> None:
|
|
384
394
|
if self._power is None or not self._power.enabled:
|
|
@@ -387,7 +397,7 @@ class Controller:
|
|
|
387
397
|
updated = await self._power.refresh()
|
|
388
398
|
|
|
389
399
|
if updated and notify:
|
|
390
|
-
self.
|
|
400
|
+
self._event_coordinator.power_update(self)
|
|
391
401
|
|
|
392
402
|
async def _refresh_zones(self, notify: bool = True) -> None:
|
|
393
403
|
"""Refresh the Zone information."""
|
|
@@ -396,7 +406,7 @@ class Controller:
|
|
|
396
406
|
*[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)]
|
|
397
407
|
)
|
|
398
408
|
|
|
399
|
-
async def _refresh_zone_group(self, group: int, notify: bool = True):
|
|
409
|
+
async def _refresh_zone_group(self, group: int, notify: bool = True) -> None:
|
|
400
410
|
assert group in [0, 4, 8]
|
|
401
411
|
zone_data_part = await self._get_resource(f"Zones{group + 1}_{group + 4}")
|
|
402
412
|
|
|
@@ -405,25 +415,31 @@ class Controller:
|
|
|
405
415
|
# pylint: disable=protected-access
|
|
406
416
|
self.zones[i + group]._update_zone(zone_data, notify)
|
|
407
417
|
|
|
408
|
-
def _refresh_address(self, address):
|
|
418
|
+
def _refresh_address(self, address: str) -> None:
|
|
409
419
|
"""Called from discovery to update the address"""
|
|
410
420
|
self._ip = address
|
|
411
421
|
# Signal to the retry connection loop to have another go.
|
|
412
422
|
if self._fail_exception:
|
|
413
|
-
self.
|
|
423
|
+
self._discovery_service.create_task(self._retry_connection())
|
|
414
424
|
|
|
415
|
-
def _get_system_state(self, state):
|
|
425
|
+
def _get_system_state(self, state: str) -> Any:
|
|
416
426
|
self._ensure_connected()
|
|
417
427
|
return self._system_settings.get(state)
|
|
418
428
|
|
|
419
|
-
async def _set_system_state(
|
|
429
|
+
async def _set_system_state(
|
|
430
|
+
self,
|
|
431
|
+
state: str,
|
|
432
|
+
command: str,
|
|
433
|
+
value: DictValue,
|
|
434
|
+
send: Any | None = None,
|
|
435
|
+
) -> None:
|
|
420
436
|
if send is None:
|
|
421
437
|
send = value
|
|
422
438
|
await self._send_command_async(command, {command: send})
|
|
423
439
|
|
|
424
440
|
# Update state and trigger rescan
|
|
425
441
|
self._system_settings[state] = value
|
|
426
|
-
self.
|
|
442
|
+
self._event_coordinator.controller_update(self)
|
|
427
443
|
await self.refresh()
|
|
428
444
|
|
|
429
445
|
def _ensure_connected(self) -> None:
|
|
@@ -432,14 +448,14 @@ class Controller:
|
|
|
432
448
|
"Unable to connect to the controller"
|
|
433
449
|
) from self._fail_exception
|
|
434
450
|
|
|
435
|
-
def _failed_connection(self, ex):
|
|
451
|
+
def _failed_connection(self, ex: Exception) -> None:
|
|
436
452
|
if self._fail_exception:
|
|
437
453
|
self._fail_exception = ex
|
|
438
454
|
return
|
|
439
455
|
self._fail_exception = ex
|
|
440
|
-
if not self.
|
|
456
|
+
if not self._initialized:
|
|
441
457
|
return
|
|
442
|
-
self.
|
|
458
|
+
self._event_coordinator.controller_disconnected(self, ex)
|
|
443
459
|
|
|
444
460
|
async def _retry_connection(self) -> None:
|
|
445
461
|
_LOG.info(
|
|
@@ -453,11 +469,11 @@ class Controller:
|
|
|
453
469
|
|
|
454
470
|
self._fail_exception = None
|
|
455
471
|
|
|
456
|
-
self.
|
|
472
|
+
self._event_coordinator.controller_update(self)
|
|
457
473
|
for zone in self.zones:
|
|
458
|
-
self.
|
|
459
|
-
self.
|
|
460
|
-
self.
|
|
474
|
+
self._event_coordinator.zone_update(self, zone)
|
|
475
|
+
self._event_coordinator.power_update(self)
|
|
476
|
+
self._event_coordinator.controller_reconnected(self)
|
|
461
477
|
except ConnectionError:
|
|
462
478
|
# Expected, just carry on.
|
|
463
479
|
_LOG.warning(
|
|
@@ -466,13 +482,16 @@ class Controller:
|
|
|
466
482
|
exc_info=True,
|
|
467
483
|
)
|
|
468
484
|
|
|
469
|
-
async def _get_resource(self, resource: str):
|
|
485
|
+
async def _get_resource(self, resource: str) -> Any:
|
|
470
486
|
try:
|
|
471
|
-
session = self.
|
|
472
|
-
async with
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
487
|
+
session = self._discovery_service.session
|
|
488
|
+
async with (
|
|
489
|
+
self._sending_lock,
|
|
490
|
+
session.get(
|
|
491
|
+
f"http://{self.device_ip}/{resource}",
|
|
492
|
+
timeout=Controller.REQUEST_TIMEOUT,
|
|
493
|
+
) as response,
|
|
494
|
+
):
|
|
476
495
|
try:
|
|
477
496
|
return await response.json(content_type=None)
|
|
478
497
|
except JSONDecodeError as ex:
|
|
@@ -487,9 +506,9 @@ class Controller:
|
|
|
487
506
|
self._failed_connection(ex)
|
|
488
507
|
raise ConnectionError("Unable to connect to the controller") from ex
|
|
489
508
|
|
|
490
|
-
async def _send_command_async(self, command: str, data: Any):
|
|
509
|
+
async def _send_command_async(self, command: str, data: Any) -> str:
|
|
491
510
|
# For some reason aiohttp fragments post requests, which causes
|
|
492
|
-
# the server to fail disgracefully.
|
|
511
|
+
# the server to fail disgracefully. Implemented rough and dirty
|
|
493
512
|
# HTTP POST client.
|
|
494
513
|
loop = asyncio.get_running_loop()
|
|
495
514
|
on_complete = loop.create_future()
|
|
@@ -499,7 +518,7 @@ class Controller:
|
|
|
499
518
|
def __init__(self) -> None:
|
|
500
519
|
self.response = bytearray()
|
|
501
520
|
|
|
502
|
-
def connection_made(self, transport):
|
|
521
|
+
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
503
522
|
body = json.dumps(data).encode("latin_1")
|
|
504
523
|
header = (
|
|
505
524
|
f"POST /{command} HTTP/1.1\r\n"
|
|
@@ -508,12 +527,12 @@ class Controller:
|
|
|
508
527
|
"\r\n"
|
|
509
528
|
).encode()
|
|
510
529
|
_LOG.debug("Writing message to %s", device_ip)
|
|
511
|
-
transport.write(header + body)
|
|
530
|
+
cast(asyncio.WriteTransport, transport).write(header + body)
|
|
512
531
|
|
|
513
|
-
def data_received(self, data):
|
|
532
|
+
def data_received(self, data: bytes) -> None:
|
|
514
533
|
self.response += data
|
|
515
534
|
|
|
516
|
-
def eof_received(self):
|
|
535
|
+
def eof_received(self) -> None:
|
|
517
536
|
full = self.response.decode("latin_1")
|
|
518
537
|
if not full:
|
|
519
538
|
on_complete.set_exception(RuntimeError("Empty HTTP Response"))
|
|
@@ -536,7 +555,7 @@ class Controller:
|
|
|
536
555
|
|
|
537
556
|
# The server doesn't tolerate multiple requests in fly concurrently
|
|
538
557
|
try:
|
|
539
|
-
async with self._sending_lock, timeout(Controller.REQUEST_TIMEOUT):
|
|
558
|
+
async with self._sending_lock, asyncio.timeout(Controller.REQUEST_TIMEOUT):
|
|
540
559
|
await loop.create_connection(_PostProtocol, self.device_ip, 80)
|
|
541
560
|
await on_complete
|
|
542
561
|
|
pizone/discovery.py
CHANGED
|
@@ -2,26 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
-
from abc import ABC, abstractmethod
|
|
6
5
|
from asyncio import (
|
|
7
6
|
CancelledError,
|
|
8
7
|
Condition,
|
|
9
8
|
DatagramProtocol,
|
|
10
9
|
DatagramTransport,
|
|
11
|
-
Future,
|
|
12
10
|
Task,
|
|
13
11
|
)
|
|
14
|
-
from
|
|
15
|
-
from typing import
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from typing import Any
|
|
16
14
|
|
|
17
|
-
import netifaces
|
|
15
|
+
import netifaces # type: ignore
|
|
18
16
|
from aiohttp import ClientSession
|
|
19
|
-
from async_timeout import timeout
|
|
20
17
|
|
|
21
18
|
from .controller import Controller
|
|
22
19
|
from .zone import Zone
|
|
23
20
|
|
|
24
|
-
DISCOVERY_MSG = b"IASD"
|
|
21
|
+
DISCOVERY_MSG = b"IASD" # cspell:disable-line
|
|
25
22
|
DISCOVERY_PORT = 12107
|
|
26
23
|
|
|
27
24
|
UPDATE_PORT = 7005
|
|
@@ -29,11 +26,11 @@ CHANGED_SYSTEM = b"iZoneChanged_System"
|
|
|
29
26
|
CHANGED_ZONES = b"iZoneChanged_Zones"
|
|
30
27
|
CHANGED_SCHEDULES = b"iZoneChanged_Schedules"
|
|
31
28
|
|
|
32
|
-
DISCOVERY_TIMEOUT = 2.0
|
|
33
29
|
DISCOVERY_SLEEP = 5.0 * 60.0
|
|
34
30
|
DISCOVERY_RESCAN = 20.0
|
|
31
|
+
RESCAN_COOLDOWN = 5.0
|
|
35
32
|
|
|
36
|
-
_LOG = logging.getLogger("pizone.discovery")
|
|
33
|
+
_LOG = logging.getLogger("pizone.discovery")
|
|
37
34
|
|
|
38
35
|
|
|
39
36
|
class LogExceptions:
|
|
@@ -42,10 +39,10 @@ class LogExceptions:
|
|
|
42
39
|
def __init__(self, func: str) -> None:
|
|
43
40
|
self.func = func
|
|
44
41
|
|
|
45
|
-
def __enter__(self):
|
|
42
|
+
def __enter__(self) -> "LogExceptions":
|
|
46
43
|
return self
|
|
47
44
|
|
|
48
|
-
def __exit__(self, exc_type, exc_value, traceback):
|
|
45
|
+
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> bool:
|
|
49
46
|
if exc_type:
|
|
50
47
|
_LOG.error(
|
|
51
48
|
"Exception ignored when calling listener %s", self.func, exc_info=True
|
|
@@ -73,12 +70,12 @@ class Listener:
|
|
|
73
70
|
"""
|
|
74
71
|
|
|
75
72
|
def controller_update(self, ctrl: Controller) -> None:
|
|
76
|
-
"""Called when a system update message is
|
|
73
|
+
"""Called when a system update message is received from the controller.
|
|
77
74
|
Controller data will be set to new value.
|
|
78
75
|
"""
|
|
79
76
|
|
|
80
77
|
def zone_update(self, ctrl: Controller, zone: Zone) -> None:
|
|
81
|
-
"""Called when a zone update message is
|
|
78
|
+
"""Called when a zone update message is received from the controller
|
|
82
79
|
Zone data will be set to new value.
|
|
83
80
|
"""
|
|
84
81
|
|
|
@@ -86,88 +83,96 @@ class Listener:
|
|
|
86
83
|
"""Called when the power monitor updates."""
|
|
87
84
|
|
|
88
85
|
|
|
89
|
-
class DiscoveryService
|
|
90
|
-
"""
|
|
86
|
+
class DiscoveryService:
|
|
87
|
+
"""Discovery service: manages controller registry, listener fanout, and UDP scanning."""
|
|
91
88
|
|
|
92
|
-
|
|
93
|
-
manager. When used in the context manager version, the start
|
|
94
|
-
discovery and close will be called automatically when opening
|
|
95
|
-
and closing the context respectively.
|
|
96
|
-
"""
|
|
97
|
-
|
|
98
|
-
@abstractmethod
|
|
99
|
-
def add_listener(self, listener: Listener) -> None:
|
|
100
|
-
"""Add a listener.
|
|
101
|
-
|
|
102
|
-
All existing controllers will be passed to the listener."""
|
|
103
|
-
|
|
104
|
-
@abstractmethod
|
|
105
|
-
def remove_listener(self, listener: Listener) -> None:
|
|
106
|
-
"""Remove a listener"""
|
|
107
|
-
|
|
108
|
-
@abstractmethod
|
|
109
|
-
async def start_discovery(self) -> None:
|
|
110
|
-
"""Async version to start discovery.
|
|
111
|
-
Will return once discovery is started, but before any controllers
|
|
112
|
-
are found.
|
|
113
|
-
"""
|
|
114
|
-
|
|
115
|
-
@abstractmethod
|
|
116
|
-
async def rescan(self) -> None:
|
|
117
|
-
"""Trigger rescan for new controllers / update IP addresses of
|
|
118
|
-
existing controllers.
|
|
119
|
-
|
|
120
|
-
Returns immediately, listener will be called with any new
|
|
121
|
-
controllers or if reconnected.
|
|
122
|
-
"""
|
|
123
|
-
|
|
124
|
-
@abstractmethod
|
|
125
|
-
async def close(self) -> None:
|
|
126
|
-
"""Stop discovery and close all connections"""
|
|
127
|
-
|
|
128
|
-
@property
|
|
129
|
-
def is_closed(self) -> bool:
|
|
130
|
-
"""Return true if closed"""
|
|
131
|
-
|
|
132
|
-
@property
|
|
133
|
-
def controllers(self) -> Dict[str, Controller]:
|
|
134
|
-
"""Dictionary of all the currently discovered controllers"""
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
138
|
-
"""Discovery protocol class. Not for external use."""
|
|
139
|
-
|
|
140
|
-
def __init__(self, session: ClientSession = None) -> None:
|
|
89
|
+
def __init__(self, session: ClientSession | None = None) -> None:
|
|
141
90
|
"""Start the discovery protocol using the supplied loop.
|
|
142
91
|
|
|
143
92
|
raises:
|
|
144
93
|
RuntimeError: If attempted to start the protocol when it is
|
|
145
94
|
already running.
|
|
146
95
|
"""
|
|
147
|
-
self._controllers
|
|
148
|
-
self._disconnected = set()
|
|
149
|
-
self._listeners
|
|
150
|
-
self._close_task
|
|
96
|
+
self._controllers: dict[str, Controller] = {}
|
|
97
|
+
self._disconnected: set[str] = set()
|
|
98
|
+
self._listeners: list[Listener] = []
|
|
99
|
+
self._close_task: Task | None = None
|
|
151
100
|
|
|
152
101
|
_LOG.info("Starting discovery protocol")
|
|
153
|
-
self.
|
|
102
|
+
self._session = session
|
|
154
103
|
self._own_session = session is None
|
|
155
104
|
|
|
156
|
-
self._transport
|
|
157
|
-
|
|
158
|
-
self._scan_condition = Condition()
|
|
159
|
-
|
|
160
|
-
|
|
105
|
+
self._transport: DatagramTransport | None = None
|
|
106
|
+
|
|
107
|
+
self._scan_condition = Condition()
|
|
108
|
+
self._last_rescan_time: float = 0.0
|
|
109
|
+
|
|
110
|
+
self._tasks: list[Task] = []
|
|
111
|
+
|
|
112
|
+
_srv = self
|
|
113
|
+
|
|
114
|
+
class _EventCoordinator(Listener):
|
|
115
|
+
"""Fan-out adapter: dispatches controller/zone events to all registered listeners."""
|
|
116
|
+
|
|
117
|
+
# pylint: disable=protected-access
|
|
118
|
+
|
|
119
|
+
def controller_discovered(self, ctrl: Controller) -> None:
|
|
120
|
+
_LOG.info(
|
|
121
|
+
"New controller found: id=%s ip=%s", ctrl.device_uid, ctrl.device_ip
|
|
122
|
+
)
|
|
123
|
+
for listener in _srv._listeners:
|
|
124
|
+
with LogExceptions("controller_discovered"):
|
|
125
|
+
listener.controller_discovered(ctrl)
|
|
126
|
+
|
|
127
|
+
def controller_disconnected(self, ctrl: Controller, ex: Exception) -> None:
|
|
128
|
+
_LOG.warning(
|
|
129
|
+
"Connection to controller lost: id=%s ip=%s",
|
|
130
|
+
ctrl.device_uid,
|
|
131
|
+
ctrl.device_ip,
|
|
132
|
+
)
|
|
133
|
+
_srv._disconnected.add(ctrl.device_uid)
|
|
134
|
+
_srv.create_task(_srv._rescan())
|
|
135
|
+
for listener in _srv._listeners:
|
|
136
|
+
with LogExceptions("controller_disconnected"):
|
|
137
|
+
listener.controller_disconnected(ctrl, ex)
|
|
138
|
+
|
|
139
|
+
def controller_reconnected(self, ctrl: Controller) -> None:
|
|
140
|
+
_LOG.warning(
|
|
141
|
+
"Controller reconnected: id=%s ip=%s",
|
|
142
|
+
ctrl.device_uid,
|
|
143
|
+
ctrl.device_ip,
|
|
144
|
+
)
|
|
145
|
+
_srv._disconnected.remove(ctrl.device_uid)
|
|
146
|
+
for listener in _srv._listeners:
|
|
147
|
+
with LogExceptions("controller_reconnected"):
|
|
148
|
+
listener.controller_reconnected(ctrl)
|
|
149
|
+
|
|
150
|
+
def controller_update(self, ctrl: Controller) -> None:
|
|
151
|
+
for listener in _srv._listeners:
|
|
152
|
+
with LogExceptions("controller_update"):
|
|
153
|
+
listener.controller_update(ctrl)
|
|
154
|
+
|
|
155
|
+
def zone_update(self, ctrl: Controller, zone: Zone) -> None:
|
|
156
|
+
for listener in _srv._listeners:
|
|
157
|
+
with LogExceptions("zone_update"):
|
|
158
|
+
listener.zone_update(ctrl, zone)
|
|
159
|
+
|
|
160
|
+
def power_update(self, ctrl: Controller) -> None:
|
|
161
|
+
for listener in _srv._listeners:
|
|
162
|
+
with LogExceptions("power_update"):
|
|
163
|
+
listener.power_update(ctrl)
|
|
164
|
+
|
|
165
|
+
self._event_coordinator: Listener = _EventCoordinator()
|
|
161
166
|
|
|
162
167
|
# Async context manager interface
|
|
163
|
-
async def __aenter__(self) -> DiscoveryService:
|
|
168
|
+
async def __aenter__(self) -> "DiscoveryService":
|
|
164
169
|
await self.start_discovery()
|
|
165
170
|
return self
|
|
166
171
|
|
|
167
|
-
async def __aexit__(self, exc_type, exc_value, traceback):
|
|
172
|
+
async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
|
|
168
173
|
await self.close()
|
|
169
174
|
|
|
170
|
-
def _task_done_callback(self, task):
|
|
175
|
+
def _task_done_callback(self, task: Task) -> None:
|
|
171
176
|
try:
|
|
172
177
|
if task.exception():
|
|
173
178
|
_LOG.exception("Uncaught exception", exc_info=task.exception())
|
|
@@ -176,9 +181,9 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
176
181
|
self._tasks.remove(task)
|
|
177
182
|
|
|
178
183
|
# managing the task list.
|
|
179
|
-
def create_task(self, coro) -> Task:
|
|
184
|
+
def create_task(self, coro: Any) -> Task:
|
|
180
185
|
"""Create a task in the event loop. Keeps track of created tasks."""
|
|
181
|
-
task = asyncio.get_running_loop().create_task(coro)
|
|
186
|
+
task: Task = asyncio.get_running_loop().create_task(coro)
|
|
182
187
|
self._tasks.append(task)
|
|
183
188
|
|
|
184
189
|
task.add_done_callback(self._task_done_callback)
|
|
@@ -191,7 +196,7 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
191
196
|
All existing controllers will be passed to the listener."""
|
|
192
197
|
self._listeners.append(listener)
|
|
193
198
|
|
|
194
|
-
def callback():
|
|
199
|
+
def callback() -> None:
|
|
195
200
|
for controller in self._controllers.values():
|
|
196
201
|
listener.controller_discovered(controller)
|
|
197
202
|
|
|
@@ -201,62 +206,36 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
201
206
|
"""Remove a listener"""
|
|
202
207
|
self._listeners.remove(listener)
|
|
203
208
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
# Non-context versions of starting.
|
|
210
|
+
async def start_discovery(self) -> None:
|
|
211
|
+
"""Start discovery protocol. Creates UDP socket and begins scanning for devices."""
|
|
212
|
+
if self._own_session:
|
|
213
|
+
self._session = ClientSession()
|
|
209
214
|
|
|
210
|
-
|
|
211
|
-
_LOG.warning(
|
|
212
|
-
"Connection to controller lost: id=%s ip=%s",
|
|
213
|
-
ctrl.device_uid,
|
|
214
|
-
ctrl.device_ip,
|
|
215
|
-
)
|
|
216
|
-
self._disconnected.add(ctrl.device_uid)
|
|
217
|
-
self.create_task(self._rescan())
|
|
218
|
-
for listener in self._listeners:
|
|
219
|
-
with LogExceptions("controller_disconnected"):
|
|
220
|
-
listener.controller_disconnected(ctrl, ex)
|
|
215
|
+
_svc = self
|
|
221
216
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
self._disconnected.remove(ctrl.device_uid)
|
|
227
|
-
for listener in self._listeners:
|
|
228
|
-
with LogExceptions("controller_reconnected"):
|
|
229
|
-
listener.controller_reconnected(ctrl)
|
|
217
|
+
class _UDPTransport(DatagramProtocol):
|
|
218
|
+
# pylint: disable=protected-access
|
|
219
|
+
def connection_made(self, transport: DatagramTransport) -> None: # type: ignore
|
|
220
|
+
_svc._on_connection_made(transport)
|
|
230
221
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
with LogExceptions("controller_update"):
|
|
234
|
-
listener.controller_update(ctrl)
|
|
235
|
-
|
|
236
|
-
def zone_update(self, ctrl: Controller, zone: Zone) -> None:
|
|
237
|
-
for listener in self._listeners:
|
|
238
|
-
with LogExceptions("zone_update"):
|
|
239
|
-
listener.zone_update(ctrl, zone)
|
|
222
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
223
|
+
_svc._on_datagram_received(data, addr)
|
|
240
224
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
with LogExceptions("power_update"):
|
|
244
|
-
listener.power_update(ctrl)
|
|
225
|
+
def connection_lost(self, exc: Exception | None) -> None:
|
|
226
|
+
_svc._on_connection_lost(exc)
|
|
245
227
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
"""Dictionary of all the currently discovered controllers"""
|
|
249
|
-
return self._controllers
|
|
228
|
+
def error_received(self, exc: Exception) -> None:
|
|
229
|
+
_svc._on_error_received(exc)
|
|
250
230
|
|
|
251
|
-
# Non-context versions of starting.
|
|
252
|
-
async def start_discovery(self) -> None:
|
|
253
|
-
if self._own_session:
|
|
254
|
-
self.session = ClientSession()
|
|
255
231
|
await asyncio.get_running_loop().create_datagram_endpoint(
|
|
256
|
-
|
|
232
|
+
_UDPTransport,
|
|
233
|
+
local_addr=("0.0.0.0", UPDATE_PORT),
|
|
234
|
+
allow_broadcast=True,
|
|
257
235
|
)
|
|
258
236
|
|
|
259
|
-
|
|
237
|
+
# Private callbacks invoked by _UDPTransport.
|
|
238
|
+
def _on_connection_made(self, transport: DatagramTransport) -> None:
|
|
260
239
|
if self._close_task:
|
|
261
240
|
transport.close()
|
|
262
241
|
return
|
|
@@ -265,7 +244,7 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
265
244
|
self._transport = transport
|
|
266
245
|
self.create_task(self._scan_loop())
|
|
267
246
|
|
|
268
|
-
def _get_broadcasts(self):
|
|
247
|
+
def _get_broadcasts(self) -> Any:
|
|
269
248
|
for ifaddr in map(netifaces.ifaddresses, netifaces.interfaces()):
|
|
270
249
|
inetaddrs = ifaddr.get(netifaces.AF_INET)
|
|
271
250
|
if not inetaddrs:
|
|
@@ -275,7 +254,8 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
275
254
|
if broadcast:
|
|
276
255
|
yield broadcast
|
|
277
256
|
|
|
278
|
-
def _send_broadcasts(self):
|
|
257
|
+
def _send_broadcasts(self) -> None:
|
|
258
|
+
assert self._transport is not None, "Discovery transport is not ready"
|
|
279
259
|
for broadcast in self._get_broadcasts():
|
|
280
260
|
_LOG.debug("Sending discovery message to addr %s", broadcast)
|
|
281
261
|
self._transport.sendto(DISCOVERY_MSG, (broadcast, DISCOVERY_PORT))
|
|
@@ -287,7 +267,7 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
287
267
|
self._send_broadcasts()
|
|
288
268
|
|
|
289
269
|
try:
|
|
290
|
-
async with timeout(
|
|
270
|
+
async with asyncio.timeout(
|
|
291
271
|
DISCOVERY_RESCAN if self._disconnected else DISCOVERY_SLEEP
|
|
292
272
|
):
|
|
293
273
|
async with self._scan_condition:
|
|
@@ -298,16 +278,62 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
298
278
|
if self._close_task:
|
|
299
279
|
return
|
|
300
280
|
|
|
301
|
-
async def rescan(self) -> None:
|
|
302
|
-
if self.is_closed:
|
|
303
|
-
raise ConnectionError("Already closed")
|
|
304
|
-
_LOG.debug("Manual rescan of controllers triggered.")
|
|
305
|
-
await self._rescan()
|
|
306
|
-
|
|
307
281
|
async def _rescan(self) -> None:
|
|
308
282
|
async with self._scan_condition:
|
|
309
283
|
self._scan_condition.notify()
|
|
310
284
|
|
|
285
|
+
async def _maybe_rescan(self) -> None:
|
|
286
|
+
"""Trigger a rescan only if outside the cool-down window."""
|
|
287
|
+
now = asyncio.get_running_loop().time()
|
|
288
|
+
if now - self._last_rescan_time >= RESCAN_COOLDOWN:
|
|
289
|
+
self._last_rescan_time = now
|
|
290
|
+
await self._rescan()
|
|
291
|
+
|
|
292
|
+
async def fetch_controller(
|
|
293
|
+
self, uid: str, timeout: float | None = None
|
|
294
|
+
) -> Controller | None:
|
|
295
|
+
"""Return the controller with *uid*, optionally waiting up to *timeout* seconds."""
|
|
296
|
+
if uid in self._controllers:
|
|
297
|
+
return self._controllers[uid]
|
|
298
|
+
if timeout is None:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
ready = asyncio.Event()
|
|
302
|
+
|
|
303
|
+
class _WaitForUidListener(Listener):
|
|
304
|
+
def controller_discovered(self, ctrl: Controller) -> None: # noqa: N805
|
|
305
|
+
if ctrl.device_uid == uid:
|
|
306
|
+
ready.set()
|
|
307
|
+
|
|
308
|
+
listener = _WaitForUidListener()
|
|
309
|
+
self.add_listener(listener)
|
|
310
|
+
try:
|
|
311
|
+
# Re-check after registering the listener (add_listener uses call_soon,
|
|
312
|
+
# so no race possible in the single-threaded event loop).
|
|
313
|
+
if uid in self._controllers:
|
|
314
|
+
return self._controllers[uid]
|
|
315
|
+
|
|
316
|
+
await self._maybe_rescan()
|
|
317
|
+
|
|
318
|
+
with suppress(TimeoutError):
|
|
319
|
+
async with asyncio.timeout(timeout):
|
|
320
|
+
await ready.wait()
|
|
321
|
+
finally:
|
|
322
|
+
self.remove_listener(listener)
|
|
323
|
+
|
|
324
|
+
return self._controllers.get(uid)
|
|
325
|
+
|
|
326
|
+
async def fetch_controllers(
|
|
327
|
+
self, timeout: float | None = None
|
|
328
|
+
) -> dict[str, Controller]:
|
|
329
|
+
"""Return all known controllers, optionally waiting for discovery to settle."""
|
|
330
|
+
if timeout is None:
|
|
331
|
+
return dict(self._controllers)
|
|
332
|
+
|
|
333
|
+
await self._maybe_rescan()
|
|
334
|
+
await asyncio.sleep(timeout)
|
|
335
|
+
return dict(self._controllers)
|
|
336
|
+
|
|
311
337
|
# Closing the connection
|
|
312
338
|
async def close(self) -> None:
|
|
313
339
|
"""Close the transport"""
|
|
@@ -322,12 +348,12 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
322
348
|
for i in self._tasks:
|
|
323
349
|
i.cancel()
|
|
324
350
|
|
|
325
|
-
if self._own_session and self.
|
|
326
|
-
await self.
|
|
351
|
+
if self._own_session and self._session:
|
|
352
|
+
await self._session.close()
|
|
327
353
|
|
|
328
354
|
await asyncio.wait(self._tasks)
|
|
329
355
|
|
|
330
|
-
def
|
|
356
|
+
def _on_connection_lost(self, exc: Exception | None) -> None:
|
|
331
357
|
_LOG.debug("Connection Lost")
|
|
332
358
|
if not self._close_task:
|
|
333
359
|
_LOG.error("Connection Lost unexpectedly: %s", repr(exc))
|
|
@@ -335,20 +361,26 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
335
361
|
|
|
336
362
|
@property
|
|
337
363
|
def is_closed(self) -> bool:
|
|
364
|
+
"""Check if the discovery service is closed."""
|
|
338
365
|
if self._transport:
|
|
339
366
|
return self._transport.is_closing()
|
|
340
367
|
return self._close_task is not None
|
|
341
368
|
|
|
342
|
-
|
|
343
|
-
|
|
369
|
+
@property
|
|
370
|
+
def session(self) -> Any:
|
|
371
|
+
"""Return the aiohttp session used for HTTP requests."""
|
|
372
|
+
return self._session
|
|
373
|
+
|
|
374
|
+
def _on_error_received(self, _: Exception) -> None:
|
|
375
|
+
_LOG.warning("Error passed and ignored to error_received", exc_info=True)
|
|
344
376
|
|
|
345
|
-
def _find_by_addr(self, addr: str) ->
|
|
377
|
+
def _find_by_addr(self, addr: tuple[str, int]) -> Controller | None:
|
|
346
378
|
for _, ctrl in self._controllers.items():
|
|
347
379
|
if ctrl.device_ip == addr[0]:
|
|
348
380
|
return ctrl
|
|
349
381
|
return None
|
|
350
382
|
|
|
351
|
-
async def _wrap_update(self, coro):
|
|
383
|
+
async def _wrap_update(self, coro: Any) -> None:
|
|
352
384
|
try:
|
|
353
385
|
await coro
|
|
354
386
|
except ConnectionError:
|
|
@@ -356,13 +388,13 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
356
388
|
"Unable to complete %s due to connection error", coro, exc_info=True
|
|
357
389
|
)
|
|
358
390
|
|
|
359
|
-
def
|
|
360
|
-
_LOG.debug("Datagram
|
|
391
|
+
def _on_datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
392
|
+
_LOG.debug("Datagram Received %s", data)
|
|
361
393
|
if self._close_task:
|
|
362
394
|
return
|
|
363
395
|
self._process_datagram(data, addr)
|
|
364
396
|
|
|
365
|
-
def _process_datagram(self, data, addr):
|
|
397
|
+
def _process_datagram(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
366
398
|
if data in (DISCOVERY_MSG, CHANGED_SCHEDULES):
|
|
367
399
|
# ignore
|
|
368
400
|
pass
|
|
@@ -377,9 +409,9 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
377
409
|
# pylint: disable=protected-access
|
|
378
410
|
self.create_task(self._wrap_update(ctrl._refresh_zones()))
|
|
379
411
|
else:
|
|
380
|
-
self.
|
|
412
|
+
self._discovery_received(data)
|
|
381
413
|
|
|
382
|
-
def
|
|
414
|
+
def _discovery_received(self, data: bytes) -> None:
|
|
383
415
|
message = data.decode().split(",")
|
|
384
416
|
if (
|
|
385
417
|
len(message) < 3
|
|
@@ -403,29 +435,31 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
403
435
|
device_uid, device_ip, is_v2, is_ipower
|
|
404
436
|
)
|
|
405
437
|
|
|
406
|
-
async def initialize_controller():
|
|
438
|
+
async def initialize_controller() -> None:
|
|
407
439
|
try:
|
|
408
440
|
await controller._initialize() # noqa: E501
|
|
409
441
|
except ConnectionError as ex:
|
|
410
442
|
_LOG.warning(
|
|
411
|
-
"Can't connect to discovered server at IP '%s'"
|
|
412
|
-
" exception: %s",
|
|
443
|
+
"Can't connect to discovered server at IP '%s' exception: %s",
|
|
413
444
|
device_ip,
|
|
414
445
|
repr(ex),
|
|
415
446
|
)
|
|
416
447
|
return
|
|
417
448
|
|
|
418
449
|
self._controllers[device_uid] = controller
|
|
419
|
-
self.controller_discovered(controller)
|
|
450
|
+
self._event_coordinator.controller_discovered(controller)
|
|
420
451
|
|
|
421
452
|
self.create_task(initialize_controller())
|
|
422
453
|
else:
|
|
423
454
|
controller = self._controllers[device_uid]
|
|
424
455
|
controller._refresh_address(device_ip)
|
|
425
456
|
|
|
426
|
-
def _create_controller(
|
|
457
|
+
def _create_controller(
|
|
458
|
+
self, device_uid: str, device_ip: str, is_v2: bool, is_ipower: bool
|
|
459
|
+
) -> Controller:
|
|
427
460
|
return Controller(
|
|
428
461
|
self,
|
|
462
|
+
self._event_coordinator,
|
|
429
463
|
device_uid=device_uid,
|
|
430
464
|
device_ip=device_ip,
|
|
431
465
|
is_v2=is_v2,
|
|
@@ -433,12 +467,14 @@ class _DiscoveryServiceImpl(DiscoveryService, DatagramProtocol, Listener):
|
|
|
433
467
|
)
|
|
434
468
|
|
|
435
469
|
|
|
436
|
-
def discovery(
|
|
470
|
+
def discovery(
|
|
471
|
+
*listeners: Listener, session: ClientSession | None = None
|
|
472
|
+
) -> DiscoveryService:
|
|
437
473
|
"""Create discovery service. Returned object is a asynchronous
|
|
438
474
|
context manager so can be used with 'async with' statement.
|
|
439
475
|
Alternately call start_discovery or start_discovery_async to commence
|
|
440
476
|
the discovery process."""
|
|
441
|
-
service =
|
|
477
|
+
service = DiscoveryService(session=session)
|
|
442
478
|
for listener in listeners:
|
|
443
479
|
service.add_listener(listener)
|
|
444
480
|
return service
|
pizone/power.py
CHANGED
|
@@ -9,8 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
11
|
from enum import IntEnum, unique
|
|
12
|
-
from typing import Any, Dict, Iterable, List,
|
|
13
|
-
|
|
12
|
+
from typing import Any, Dict, Iterable, List, Set, Tuple
|
|
14
13
|
|
|
15
14
|
_LOG = logging.getLogger("pizone.power")
|
|
16
15
|
|
|
@@ -35,7 +34,7 @@ class BatteryLevel(IntEnum):
|
|
|
35
34
|
class PowerChannel:
|
|
36
35
|
"""Channel within power device"""
|
|
37
36
|
|
|
38
|
-
def __init__(self, device: PowerDevice, index: int):
|
|
37
|
+
def __init__(self, device: PowerDevice, index: int) -> None:
|
|
39
38
|
self._device = device
|
|
40
39
|
self._index = index
|
|
41
40
|
|
|
@@ -70,7 +69,7 @@ class PowerChannel:
|
|
|
70
69
|
return self._config["Name"]
|
|
71
70
|
|
|
72
71
|
@property
|
|
73
|
-
def group_number(self) ->
|
|
72
|
+
def group_number(self) -> int | None:
|
|
74
73
|
"""Group number"""
|
|
75
74
|
num = self._config["GrNo"]
|
|
76
75
|
return num if num < 255 else None
|
|
@@ -94,7 +93,7 @@ class PowerChannel:
|
|
|
94
93
|
class PowerDevice:
|
|
95
94
|
"""Device for power information."""
|
|
96
95
|
|
|
97
|
-
def __init__(self, power: Power, index: int):
|
|
96
|
+
def __init__(self, power: Power, index: int) -> None:
|
|
98
97
|
self._power = power
|
|
99
98
|
self._index = index
|
|
100
99
|
self._channels = tuple(PowerChannel(self, i) for i in range(0, 3))
|
|
@@ -138,7 +137,7 @@ class PowerDevice:
|
|
|
138
137
|
class PowerGroup:
|
|
139
138
|
"""Grouped power devices"""
|
|
140
139
|
|
|
141
|
-
def __init__(self, power: Power, channels: Iterable[PowerChannel]):
|
|
140
|
+
def __init__(self, power: Power, channels: Iterable[PowerChannel]) -> None:
|
|
142
141
|
self._power = power
|
|
143
142
|
self._channels = tuple(channels)
|
|
144
143
|
# get unique devices
|
|
@@ -171,13 +170,13 @@ class PowerGroup:
|
|
|
171
170
|
class Power:
|
|
172
171
|
"""Contains power information."""
|
|
173
172
|
|
|
174
|
-
def __init__(self, controller) -> None:
|
|
173
|
+
def __init__(self, controller: Any) -> None:
|
|
175
174
|
"""Init function."""
|
|
176
|
-
self._controller = controller
|
|
177
|
-
self._config
|
|
178
|
-
self._status = {"LastReadingNo": -1}
|
|
175
|
+
self._controller = controller
|
|
176
|
+
self._config: Dict[str, Any] = {}
|
|
177
|
+
self._status: Dict[str, Any] = {"LastReadingNo": -1}
|
|
179
178
|
self._devices = tuple(PowerDevice(self, i) for i in range(0, 5))
|
|
180
|
-
self._groups
|
|
179
|
+
self._groups: Tuple[PowerGroup, ...] | None = None
|
|
181
180
|
|
|
182
181
|
async def init(self) -> None:
|
|
183
182
|
"""Initialise the power settings."""
|
|
@@ -191,7 +190,7 @@ class Power:
|
|
|
191
190
|
|
|
192
191
|
async def refresh(self) -> bool:
|
|
193
192
|
"""Refreshes the power usage data."""
|
|
194
|
-
status = await self._do_request(2, "PowerMonitorStatus")
|
|
193
|
+
status: Dict[str, Any] = await self._do_request(2, "PowerMonitorStatus")
|
|
195
194
|
|
|
196
195
|
if status["LastReadingNo"] == self._status["LastReadingNo"]:
|
|
197
196
|
return False
|
|
@@ -243,6 +242,6 @@ class Power:
|
|
|
243
242
|
return self._devices
|
|
244
243
|
|
|
245
244
|
@property
|
|
246
|
-
def groups(self) ->
|
|
245
|
+
def groups(self) -> Tuple[PowerGroup, ...] | None:
|
|
247
246
|
"""Available power groups."""
|
|
248
247
|
return self._groups
|
pizone/version.py
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
|
-
#
|
|
2
|
-
# file generated by setuptools_scm
|
|
1
|
+
# file generated by vcs-versioning
|
|
3
2
|
# don't change, don't track in version control
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.2.10'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 2, 10)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pizone/zone.py
CHANGED
|
@@ -5,7 +5,7 @@ Various properties allow interogation and setting of zone data.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from enum import Enum
|
|
8
|
-
from typing import
|
|
8
|
+
from typing import Any, Dict, Union
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class Zone:
|
|
@@ -37,8 +37,8 @@ class Zone:
|
|
|
37
37
|
DictValue = Union[str, int, float]
|
|
38
38
|
ZoneData = Dict[str, DictValue]
|
|
39
39
|
|
|
40
|
-
def __init__(self, controller, index: int) -> None:
|
|
41
|
-
self._zone_data = {}
|
|
40
|
+
def __init__(self, controller: Any, index: int) -> None:
|
|
41
|
+
self._zone_data: Zone.ZoneData = {}
|
|
42
42
|
self._index = index
|
|
43
43
|
self._controller = controller
|
|
44
44
|
|
|
@@ -53,7 +53,7 @@ class Zone:
|
|
|
53
53
|
return self._get_zone_state("Name")
|
|
54
54
|
|
|
55
55
|
@property
|
|
56
|
-
def type(self) -> "Type":
|
|
56
|
+
def type(self) -> "Zone.Type":
|
|
57
57
|
"""This indicates the type of the zone. Possible values are:
|
|
58
58
|
'auto' – the zone has temperature control enabled
|
|
59
59
|
'opcl' – the zone is open/close only
|
|
@@ -62,7 +62,7 @@ class Zone:
|
|
|
62
62
|
return self.Type(self._get_zone_state("Type"))
|
|
63
63
|
|
|
64
64
|
@property
|
|
65
|
-
def mode(self) -> "Mode":
|
|
65
|
+
def mode(self) -> "Zone.Mode":
|
|
66
66
|
"""This indicates the current mode the zone is in. Possible values are:
|
|
67
67
|
'open' – the zone is currently open
|
|
68
68
|
'close' – the zone is currently closed
|
|
@@ -71,12 +71,12 @@ class Zone:
|
|
|
71
71
|
return self.Mode(self._get_zone_state("Mode"))
|
|
72
72
|
|
|
73
73
|
@property
|
|
74
|
-
def temp_setpoint(self) ->
|
|
74
|
+
def temp_setpoint(self) -> float | None:
|
|
75
75
|
"""Temp setpoint in degrees C."""
|
|
76
76
|
return self._get_zone_state("SetPoint") or None
|
|
77
77
|
|
|
78
78
|
@property
|
|
79
|
-
def temp_current(self) ->
|
|
79
|
+
def temp_current(self) -> float | None:
|
|
80
80
|
"""Current zone temperature"""
|
|
81
81
|
return self._get_zone_state("Temp") or None
|
|
82
82
|
|
|
@@ -142,7 +142,7 @@ class Zone:
|
|
|
142
142
|
self._zone_data["SetPoint"] = value
|
|
143
143
|
self._fire_listeners()
|
|
144
144
|
|
|
145
|
-
async def set_mode(self, value: Mode) -> None:
|
|
145
|
+
async def set_mode(self, value: "Zone.Mode") -> None:
|
|
146
146
|
"""Set the current zone mode.
|
|
147
147
|
Possible values are:
|
|
148
148
|
'open' – the zone is currently open
|
|
@@ -159,7 +159,7 @@ class Zone:
|
|
|
159
159
|
self._zone_data["Mode"] = value.value
|
|
160
160
|
self._fire_listeners()
|
|
161
161
|
|
|
162
|
-
def _update_zone(self, zone_data, notify: bool = True):
|
|
162
|
+
def _update_zone(self, zone_data: "Zone.ZoneData", notify: bool = True) -> None:
|
|
163
163
|
if zone_data["Index"] != self._index:
|
|
164
164
|
raise AttributeError("Can't change index of existing zone.")
|
|
165
165
|
self._zone_data = zone_data
|
|
@@ -168,13 +168,13 @@ class Zone:
|
|
|
168
168
|
|
|
169
169
|
def _fire_listeners(self) -> None:
|
|
170
170
|
# pylint: disable=protected-access
|
|
171
|
-
self._controller.
|
|
171
|
+
self._controller._event_coordinator.zone_update(self._controller, self)
|
|
172
172
|
|
|
173
|
-
def _get_zone_state(self, state):
|
|
173
|
+
def _get_zone_state(self, state: str) -> Any:
|
|
174
174
|
self._controller._ensure_connected() # pylint: disable=protected-access # noqa
|
|
175
175
|
return self._zone_data[state]
|
|
176
176
|
|
|
177
|
-
async def _send_command(self, command, data: Union[str, float, int]):
|
|
177
|
+
async def _send_command(self, command: str, data: Union[str, float, int]) -> None:
|
|
178
178
|
send_data = {command: {"ZoneNo": str(self._index + 1), "Command": str(data)}}
|
|
179
179
|
# pylint: disable=protected-access
|
|
180
180
|
await self._controller._send_command_async(command, send_data)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: python-izone
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.10
|
|
4
4
|
Summary: A python interface to the iZone airconditioner controller
|
|
5
5
|
Home-page: https://github.com/Swamp-Ig/pizone
|
|
6
6
|
Author: Penny Wood
|
|
@@ -8,25 +8,25 @@ Author-email: pypl@ninjateaparty.com
|
|
|
8
8
|
Keywords: iZone,IoT
|
|
9
9
|
Classifier: Development Status :: 5 - Production/Stable
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
12
|
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
14
14
|
Classifier: Topic :: Home Automation
|
|
15
15
|
Classifier: Topic :: System :: Hardware
|
|
16
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.14
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: licence.txt
|
|
19
|
-
Requires-Dist: aiohttp
|
|
20
|
-
Requires-Dist: netifaces
|
|
21
|
-
Requires-Dist: async-timeout
|
|
22
|
-
Provides-Extra: all
|
|
23
|
-
Requires-Dist: pytest (>=6.2.2) ; extra == 'all'
|
|
24
|
-
Requires-Dist: pytest-aio ; extra == 'all'
|
|
25
|
-
Requires-Dist: pytest-aiohttp ; extra == 'all'
|
|
19
|
+
Requires-Dist: aiohttp>=3.10
|
|
20
|
+
Requires-Dist: netifaces>=0.11
|
|
26
21
|
Provides-Extra: test
|
|
27
|
-
Requires-Dist: pytest
|
|
28
|
-
Requires-Dist: pytest-aio
|
|
29
|
-
Requires-Dist: pytest-aiohttp
|
|
22
|
+
Requires-Dist: pytest>=6.2.2; extra == "test"
|
|
23
|
+
Requires-Dist: pytest-aio; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-aiohttp; extra == "test"
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Requires-Dist: pytest>=6.2.2; extra == "all"
|
|
27
|
+
Requires-Dist: pytest-aio; extra == "all"
|
|
28
|
+
Requires-Dist: pytest-aiohttp; extra == "all"
|
|
29
|
+
Dynamic: license-file
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
# piZone - python interface to iZone aircon system
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pizone/__init__.py,sha256=MSm2bF2eZCqO4USVob4IHaWwdeG-ygvdKQJxbTeqDW0,507
|
|
2
|
+
pizone/controller.py,sha256=d6m8rWgPQ9MArC_Z6ejkcQIvmLoSPMJRGtu78S84m7o,20736
|
|
3
|
+
pizone/discovery.py,sha256=I9-HM40O6PWzRriRJHLgDuv4MNA16rtlKvqbUEknWsA,16809
|
|
4
|
+
pizone/power.py,sha256=BkgOQXhK39CGYUwgpMHrR37lwztfc-ItMkH7Ay6mBoA,6694
|
|
5
|
+
pizone/version.py,sha256=MHv5TrXn6T-L85hrOYm8mAeGoL3s8Fammbmt6HNhexA,522
|
|
6
|
+
pizone/zone.py,sha256=4DEaS_iFiFNXPQ3GplKiKlmyRPGtJj4ek5OZQKwD1Ng,6463
|
|
7
|
+
python_izone-1.2.10.dist-info/licenses/licence.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
8
|
+
python_izone-1.2.10.dist-info/METADATA,sha256=K4m_Ix8hRG3z3gFGcz2IRkIUXtzii3gG_17C2S75yOM,1365
|
|
9
|
+
python_izone-1.2.10.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
python_izone-1.2.10.dist-info/top_level.txt,sha256=VRPVB7Bq60Bqh6N4d-U1xvOkXcQbsiN1zxnrajPL2Xg,7
|
|
11
|
+
python_izone-1.2.10.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
pizone/__init__.py,sha256=TG3TLQTOcvr74j-OrDyqFHkLRabrgE30N8k-l5CcrHk,487
|
|
2
|
-
pizone/controller.py,sha256=Ie6zlXK82Gu-TQFB1vQNS8TOF_KgKGPZei0xwBcJ5U0,20108
|
|
3
|
-
pizone/discovery.py,sha256=VCfd422j1DmKvZ-nRs2bZ3KSklSgvB2gZKkOGHhHQOo,14832
|
|
4
|
-
pizone/power.py,sha256=LnGlLLo51sBikVJr7v8k2h1W-1a4qai30To1JmkJmTg,6733
|
|
5
|
-
pizone/version.py,sha256=bntNoig_UKPNqaLflAKmPVAKv-l0ffT7k1DuXIq23pc,142
|
|
6
|
-
pizone/zone.py,sha256=GsjtIczJ-A1YBDhP5X28e4UWmKj_-fm07vYqGHnt7JM,6393
|
|
7
|
-
python_izone-1.2.9.dist-info/METADATA,sha256=dxzG2cY3NFpwLCBGV7kcXqAHaUDobud30J7YkcT9yJc,1377
|
|
8
|
-
python_izone-1.2.9.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
|
|
9
|
-
python_izone-1.2.9.dist-info/licence.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
10
|
-
python_izone-1.2.9.dist-info/top_level.txt,sha256=VRPVB7Bq60Bqh6N4d-U1xvOkXcQbsiN1zxnrajPL2Xg,7
|
|
11
|
-
python_izone-1.2.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|