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 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
- __ALL__ = [
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, List, Optional, Union
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
- } # type: Dict[str, List[Fan]]
65
+ }
62
66
 
63
67
  def __init__(
64
- self, discovery, device_uid: str, device_ip: str, is_v2: bool, is_ipower: bool
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._discovery = discovery
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 = [] # type: List[Zone]
92
- self.fan_modes = [] # type: List[Controller.Fan]
93
- self._system_settings = {} # type: Controller.ControllerData
94
- self._power = None # type: Optional[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._initialised = False
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
- initialised."""
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._initialised = True
124
- self._discovery.create_task(self._poll_loop())
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._discovery.is_closed:
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 exeption.", exc_info=True)
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) -> Optional[Power]:
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._discovery
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) -> Optional[float]:
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) -> Optional[float]:
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) -> Optional[float]:
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 maxium, or 30 if eco lock not set"""
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 unmatching device ID")
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._discovery.controller_update(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._discovery.power_update(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._discovery.create_task(self._retry_connection())
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(self, state, command, value, send=None):
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._discovery.controller_update(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._initialised:
456
+ if not self._initialized:
441
457
  return
442
- self._discovery.controller_disconnected(self, ex)
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._discovery.controller_update(self)
472
+ self._event_coordinator.controller_update(self)
457
473
  for zone in self.zones:
458
- self._discovery.zone_update(self, zone)
459
- self._discovery.power_update(self)
460
- self._discovery.controller_reconnected(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._discovery.session
472
- async with self._sending_lock, session.get(
473
- f"http://{self.device_ip}/{resource}",
474
- timeout=Controller.REQUEST_TIMEOUT,
475
- ) as response:
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. Implimented rough and dirty
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 logging import Logger
15
- from typing import Dict, List, Optional, Set
12
+ from contextlib import suppress
13
+ from typing import Any
16
14
 
17
- import netifaces # type: ignore
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") # type: Logger
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 recieved from the controller.
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 recieved from the controller
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(ABC):
90
- """Interface for discovery.
86
+ class DiscoveryService:
87
+ """Discovery service: manages controller registry, listener fanout, and UDP scanning."""
91
88
 
92
- This service is both a context manager, and an asynchronous context
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 = {} # type: Dict[str, Controller]
148
- self._disconnected = set() # type: Set[str]
149
- self._listeners = [] # type: List[Listener]
150
- self._close_task = None # type: Optional[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.session = session
102
+ self._session = session
154
103
  self._own_session = session is None
155
104
 
156
- self._transport = None # type: Optional[DatagramTransport]
157
-
158
- self._scan_condition = Condition() # type: Condition
159
-
160
- self._tasks = [] # type: List[Future]
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) # type: Task
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
- def controller_discovered(self, ctrl: Controller) -> None:
205
- _LOG.info("New controller found: id=%s ip=%s", ctrl.device_uid, ctrl.device_ip)
206
- for listener in self._listeners:
207
- with LogExceptions("controller_discovered"):
208
- listener.controller_discovered(ctrl)
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
- def controller_disconnected(self, ctrl: Controller, ex: Exception) -> None:
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
- def controller_reconnected(self, ctrl: Controller) -> None:
223
- _LOG.warning(
224
- "Controller reconnected: id=%s ip=%s", ctrl.device_uid, ctrl.device_ip
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
- def controller_update(self, ctrl: Controller) -> None:
232
- for listener in self._listeners:
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
- def power_update(self, ctrl: Controller) -> None:
242
- for listener in self._listeners:
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
- @property
247
- def controllers(self) -> Dict[str, Controller]:
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
- lambda: self, local_addr=("0.0.0.0", UPDATE_PORT), allow_broadcast=True
232
+ _UDPTransport,
233
+ local_addr=("0.0.0.0", UPDATE_PORT),
234
+ allow_broadcast=True,
257
235
  )
258
236
 
259
- def connection_made(self, transport: DatagramTransport) -> None: # type: ignore # noqa: E501
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.session:
326
- await self.session.close()
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 connection_lost(self, exc):
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
- def error_received(self, exc):
343
- _LOG.warning("Error passed and ignored to error_recieved", exc_info=True)
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) -> Optional[Controller]:
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 datagram_received(self, data, addr):
360
- _LOG.debug("Datagram Recieved %s", data)
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._discovery_recieved(data)
412
+ self._discovery_received(data)
381
413
 
382
- def _discovery_recieved(self, data):
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(self, device_uid, device_ip, is_v2, is_ipower):
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(*listeners: Listener, session: ClientSession = None) -> DiscoveryService:
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 = _DiscoveryServiceImpl(session=session)
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, Optional, Set, Tuple
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) -> Optional[int]:
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 # type: ignore
177
- self._config = {} # type: Dict[str, Any]
178
- self._status = {"LastReadingNo": -1} # type: Dict[str, Any]
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 = None # type: Optional[Tuple[PowerGroup, ...]]
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") # type: Dict[str, Any]
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) -> Optional[Tuple[PowerGroup, ...]]:
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
- # coding: utf-8
2
- # file generated by setuptools_scm
1
+ # file generated by vcs-versioning
3
2
  # don't change, don't track in version control
4
- version = '1.2.9'
5
- version_tuple = (1, 2, 9)
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 Dict, Optional, Union
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 = {} # type: Dict
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) -> Optional[float]:
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) -> Optional[float]:
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._discovery.zone_update(self._controller, self)
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
1
+ Metadata-Version: 2.4
2
2
  Name: python-izone
3
- Version: 1.2.9
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.9
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.9
16
+ Requires-Python: >=3.14
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: licence.txt
19
- Requires-Dist: aiohttp (>=3.4)
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 (>=6.2.2) ; extra == 'test'
28
- Requires-Dist: pytest-aio ; extra == 'test'
29
- Requires-Dist: pytest-aiohttp ; extra == 'test'
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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.37.1)
2
+ Generator: setuptools (82.0.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,