python-broadlink 1.0.2__py3-none-any.whl → 1.0.4__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.
broadlink/__init__.py CHANGED
@@ -1,16 +1,14 @@
1
- #!/usr/bin/env python3
2
1
  """The python-broadlink library."""
3
2
 
4
3
  import contextlib
5
- from collections.abc import AsyncIterator
6
- from typing import Optional, Union
4
+ from collections.abc import AsyncGenerator
7
5
 
8
6
  from . import exceptions as e
9
7
  from .alarm import S1C
10
8
  from .climate import hvac, hysen
11
9
  from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT
12
10
  from .cover import dooya, dooya2, wser
13
- from .device import Device, _open_endpoint, ping, scan
11
+ from .device import Device, ping, scan, send_setup_packet
14
12
  from .hub import s3
15
13
  from .light import lb1, lb2
16
14
  from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro
@@ -296,15 +294,15 @@ async def xdiscover(
296
294
  local_ip_address: str | None = None,
297
295
  discover_ip_address: str = DEFAULT_BCAST_ADDR,
298
296
  discover_ip_port: int = DEFAULT_PORT,
299
- ) -> AsyncIterator[Device]:
297
+ ) -> AsyncGenerator[Device]:
300
298
  """Discover devices connected to the local network.
301
299
 
302
300
  Yields each device as soon as it answers.
303
301
  """
304
- async for resp in scan(
305
- timeout, local_ip_address, discover_ip_address, discover_ip_port
306
- ):
307
- yield gendevice(*resp)
302
+ responses = scan(timeout, local_ip_address, discover_ip_address, discover_ip_port)
303
+ async with contextlib.aclosing(responses):
304
+ async for resp in responses:
305
+ yield gendevice(*resp)
308
306
 
309
307
 
310
308
  # Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode.
@@ -340,8 +338,4 @@ async def setup(
340
338
  payload[0x20] = checksum & 0xFF # Checksum 1 position
341
339
  payload[0x21] = checksum >> 8 # Checksum 2 position
342
340
 
343
- transport, _ = await _open_endpoint(broadcast=True)
344
- try:
345
- transport.sendto(payload, (ip_address, DEFAULT_PORT))
346
- finally:
347
- transport.close()
341
+ await send_setup_packet(bytes(payload), ip_address)
broadlink/device.py CHANGED
@@ -15,7 +15,7 @@ import contextlib
15
15
  import logging
16
16
  import random
17
17
  import socket
18
- from collections.abc import AsyncIterator
18
+ from collections.abc import AsyncGenerator
19
19
 
20
20
  from cryptography.hazmat.backends import default_backend
21
21
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -48,33 +48,55 @@ _RECENT_MAX = 64
48
48
  _CLOSED = (None, None)
49
49
  """Sentinel put on the receive queue when the endpoint is closed."""
50
50
 
51
+ _QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
52
+ """What the receive queue carries: a datagram with its source address, an
53
+ error the socket reported (address ``None``), or ``_CLOSED``."""
54
+
51
55
 
52
56
  class _Protocol(asyncio.DatagramProtocol):
53
- """Datagram protocol that hands every received packet to a queue."""
57
+ """Datagram protocol that hands every received packet to a queue.
58
+
59
+ Errors the socket reports go on the same queue, so the request that is
60
+ waiting fails at once instead of waiting out its timeout.
61
+ """
54
62
 
55
63
  def __init__(self) -> None:
56
- self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
64
+ self.queue: asyncio.Queue[_QueueItem] = asyncio.Queue()
57
65
  self.transport: asyncio.DatagramTransport | None = None
58
66
 
59
- def connection_made(self, transport) -> None: # type: ignore[override]
60
- self.transport = transport
67
+ def connection_made(self, transport: asyncio.BaseTransport) -> None:
68
+ """Keep the transport; the endpoint sends through it."""
69
+ self.transport = transport # type: ignore[assignment]
61
70
 
62
71
  def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
72
+ """Queue every datagram for the request that is waiting."""
63
73
  self.queue.put_nowait((data, addr))
64
74
 
65
75
  def error_received(self, exc: Exception) -> None:
66
- # ICMP unreachable and the like. Surface it as a receive of nothing;
67
- # the retry loop will time out and raise NetworkTimeoutError.
68
- pass
76
+ """Queue a send failure or an ICMP error for the waiting request."""
77
+ self.queue.put_nowait((exc, None))
69
78
 
70
79
  def connection_lost(self, exc: Exception | None) -> None:
71
- pass
80
+ """Wake the waiting request if asyncio closed the transport on us."""
81
+ self.queue.put_nowait((exc, None) if exc is not None else _CLOSED)
72
82
 
73
83
  def drain(self) -> None:
74
84
  """Drop anything that arrived before the current request."""
75
85
  while not self.queue.empty():
76
86
  self.queue.get_nowait()
77
87
 
88
+ def raise_if_error(self) -> None:
89
+ """Raise the error a send just reported, if it reported one.
90
+
91
+ asyncio delivers a failed ``sendto`` to ``error_received`` before
92
+ ``sendto`` returns, so a fire-and-forget sender can check right
93
+ after sending and raise the ``OSError`` the way a plain socket did.
94
+ """
95
+ while not self.queue.empty():
96
+ item, _ = self.queue.get_nowait()
97
+ if isinstance(item, Exception):
98
+ raise item
99
+
78
100
 
79
101
  async def _open_endpoint(
80
102
  local_addr: tuple[str, int] | None = None,
@@ -93,6 +115,21 @@ async def _open_endpoint(
93
115
  return transport, protocol # type: ignore[return-value]
94
116
 
95
117
 
118
+ async def _resolve(host: str, port: int) -> tuple[str, int]:
119
+ """Resolve a destination once, off the event loop.
120
+
121
+ Sending to a hostname through an unconnected datagram socket would
122
+ resolve it with a blocking call on the loop and hide the failure. A
123
+ name that does not resolve raises ``socket.gaierror`` here, as the
124
+ original library's ``sendto`` did.
125
+ """
126
+ loop = asyncio.get_running_loop()
127
+ info = await loop.getaddrinfo(
128
+ host, port, family=socket.AF_INET, type=socket.SOCK_DGRAM
129
+ )
130
+ return info[0][4][:2] # type: ignore[return-value]
131
+
132
+
96
133
  def _hello_packet(local_ip_address: str, port: int) -> bytearray:
97
134
  packet = bytearray(0x30)
98
135
  packet[0x08:0x14] = Datetime.pack(Datetime.now())
@@ -117,13 +154,14 @@ async def scan(
117
154
  local_ip_address: str | None = None,
118
155
  discover_ip_address: str = DEFAULT_BCAST_ADDR,
119
156
  discover_ip_port: int = DEFAULT_PORT,
120
- ) -> AsyncIterator[HelloResponse]:
157
+ ) -> AsyncGenerator[HelloResponse]:
121
158
  """Broadcast a hello message and yield responses as they arrive.
122
159
 
123
160
  The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until
124
161
  ``timeout`` elapses. Each device is yielded once.
125
162
  """
126
163
  local_addr = (local_ip_address, 0) if local_ip_address else None
164
+ target = await _resolve(discover_ip_address, discover_ip_port)
127
165
  transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True)
128
166
  try:
129
167
  if local_ip_address:
@@ -138,7 +176,7 @@ async def scan(
138
176
  discovered: set[tuple[tuple[str, int], bytes, int]] = set()
139
177
 
140
178
  while (loop.time() - start) < timeout:
141
- transport.sendto(packet, (discover_ip_address, discover_ip_port))
179
+ transport.sendto(packet, target)
142
180
  deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start))
143
181
  slot_end = loop.time() + deadline
144
182
  while True:
@@ -149,7 +187,11 @@ async def scan(
149
187
  resp, host = await asyncio.wait_for(protocol.queue.get(), remaining)
150
188
  except TimeoutError:
151
189
  break
152
- if len(resp) < 0x80:
190
+ if resp is None:
191
+ return # The transport was closed under us.
192
+ if isinstance(resp, Exception):
193
+ raise resp
194
+ if host is None or len(resp) < 0x80:
153
195
  continue
154
196
  entry = _parse_hello(resp, host)
155
197
  key = (entry[1], entry[2], entry[0])
@@ -161,6 +203,19 @@ async def scan(
161
203
  transport.close()
162
204
 
163
205
 
206
+ async def send_setup_packet(
207
+ payload: bytes, ip_address: str, port: int = DEFAULT_PORT
208
+ ) -> None:
209
+ """Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
210
+ target = await _resolve(ip_address, port)
211
+ transport, protocol = await _open_endpoint(broadcast=True)
212
+ try:
213
+ transport.sendto(payload, target)
214
+ protocol.raise_if_error()
215
+ finally:
216
+ transport.close()
217
+
218
+
164
219
  async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
165
220
  """Send a ping packet to an address.
166
221
 
@@ -168,11 +223,13 @@ async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
168
223
  Useful to prevent reboots when the cloud cannot be reached.
169
224
  It must be sent every 2 minutes in such cases.
170
225
  """
171
- transport, _ = await _open_endpoint(broadcast=True)
226
+ target = await _resolve(ip_address, port)
227
+ transport, protocol = await _open_endpoint(broadcast=True)
172
228
  try:
173
229
  packet = bytearray(0x30)
174
230
  packet[0x26] = 1
175
- transport.sendto(packet, (ip_address, port))
231
+ transport.sendto(packet, target)
232
+ protocol.raise_if_error()
176
233
  finally:
177
234
  transport.close()
178
235
 
@@ -213,12 +270,13 @@ class Device:
213
270
  self.aes = None
214
271
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
215
272
 
216
- self._lock: asyncio.Lock | None = None
273
+ self._lock = asyncio.Lock()
217
274
  self._transport: asyncio.DatagramTransport | None = None
218
275
  self._protocol: _Protocol | None = None
219
276
  self._endpoint_addr: tuple[str, int] | None = None
220
277
  self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
221
- self._reauth_lock: asyncio.Lock | None = None
278
+ self._reauth_lock = asyncio.Lock()
279
+ self._closes = 0 # Bumped by aclose(); guards an open racing a close.
222
280
  self._auth_generation = 0
223
281
 
224
282
  def __repr__(self) -> str:
@@ -277,9 +335,6 @@ class Device:
277
335
  packet[0x2D] = 0x01
278
336
  packet[0x30:0x36] = b"Test 1"
279
337
 
280
- if self._lock is None:
281
- self._lock = asyncio.Lock()
282
- self._reauth_lock = asyncio.Lock()
283
338
  async with self._lock:
284
339
  self.id = 0
285
340
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
@@ -292,7 +347,7 @@ class Device:
292
347
  _LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
293
348
  return True
294
349
 
295
- async def hello(self, local_ip_address=None) -> bool:
350
+ async def hello(self, local_ip_address: str | None = None) -> bool:
296
351
  """Send a hello message to the device.
297
352
 
298
353
  Device information is checked before updating name and lock status.
@@ -385,6 +440,7 @@ class Device:
385
440
  A request in flight fails at once with ``ConnectionClosedError``
386
441
  rather than waiting out its timeout.
387
442
  """
443
+ self._closes += 1
388
444
  transport, protocol = self._transport, self._protocol
389
445
  self._transport = None
390
446
  self._protocol = None
@@ -395,13 +451,37 @@ class Device:
395
451
  if protocol is not None:
396
452
  protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type]
397
453
 
454
+ def _drop_endpoint(self) -> None:
455
+ """Throw the endpoint away after a failure; the next call reopens it.
456
+
457
+ A connected datagram socket can go bad for good (the interface
458
+ bounced, the host's address changed), and the original library
459
+ never noticed because it opened a socket per call. Dropping the
460
+ endpoint whenever a request fails restores that self-healing.
461
+ """
462
+ transport = self._transport
463
+ self._transport = None
464
+ self._protocol = None
465
+ self._endpoint_addr = None
466
+ if transport is not None:
467
+ transport.close()
468
+ _LOGGER.debug("%s: endpoint dropped after a failure", self.host[0])
469
+
398
470
  async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]:
399
471
  if self._transport is not None and self._endpoint_addr != self.host:
400
472
  # The caller changed host; the connected socket points at the
401
473
  # old address, so drop it.
402
474
  await self.aclose()
403
475
  if self._transport is None or self._transport.is_closing():
404
- self._transport, self._protocol = await _open_endpoint(remote_addr=self.host)
476
+ closes = self._closes
477
+ transport, protocol = await _open_endpoint(remote_addr=self.host)
478
+ if self._closes != closes:
479
+ # aclose() ran while the socket was being opened.
480
+ transport.close()
481
+ raise e.EndpointClosedError(
482
+ -4013, "Endpoint closed", "The device endpoint was closed"
483
+ )
484
+ self._transport, self._protocol = transport, protocol
405
485
  self._endpoint_addr = self.host
406
486
  _LOGGER.debug("%s: endpoint opened", self.host[0])
407
487
  return self._transport, self._protocol # type: ignore[return-value]
@@ -490,6 +570,19 @@ class Device:
490
570
  raise e.EndpointClosedError(
491
571
  -4013, "Endpoint closed", "The device endpoint was closed"
492
572
  )
573
+ if isinstance(resp, ConnectionRefusedError):
574
+ # ICMP port unreachable: the host is up and nothing is
575
+ # listening, or the device is rebooting. The original
576
+ # library's unconnected socket never saw these, so keep
577
+ # waiting and let the timeout decide, as it did.
578
+ _LOGGER.debug("%s: port unreachable, still waiting", self.host[0])
579
+ continue
580
+ if isinstance(resp, Exception):
581
+ # A send failure (no route, address gone) or a fatal
582
+ # transport error: fail now and throw the socket away.
583
+ _LOGGER.debug("%s: socket error: %s", self.host[0], resp)
584
+ self._drop_endpoint()
585
+ raise resp
493
586
  resp = self._validate(resp)
494
587
  reply_count = int.from_bytes(resp[0x28:0x2A], "little")
495
588
  if reply_count == count or reply_count not in self._recent:
@@ -501,33 +594,40 @@ class Device:
501
594
  )
502
595
  if loop.time() - start >= timeout:
503
596
  _LOGGER.debug("%s: no reply within %ss", self.host[0], timeout)
597
+ self._drop_endpoint()
504
598
  raise e.NetworkTimeoutError(
505
599
  -4000,
506
600
  "Network timeout",
507
601
  f"No response received within {timeout}s",
508
602
  ) from None
509
603
 
510
- async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
604
+ async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> bytes:
511
605
  """Send a packet to the device and return the raw response frame.
512
606
 
513
607
  If the device answers that the session key is no longer valid, the
514
608
  session is re-authenticated once and the request is sent again.
515
609
  Concurrent callers that hit the same expired key share one
516
- re-authentication and each retry once.
610
+ re-authentication and each retry once. If that re-authentication
611
+ fails (for example the device has been locked in the app), the
612
+ original reply is returned unchanged, so the caller sees the same
613
+ error the original library raised and can run its own recovery.
517
614
  """
518
- if self._lock is None:
519
- self._lock = asyncio.Lock()
520
- self._reauth_lock = asyncio.Lock()
521
- generation = self._auth_generation
522
615
  async with self._lock:
616
+ generation = self._auth_generation
523
617
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
524
618
 
525
619
  code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
526
620
  if code in _REAUTH_CODES:
527
621
  _LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
528
- async with self._reauth_lock: # type: ignore[union-attr]
622
+ async with self._reauth_lock:
529
623
  if self._auth_generation == generation:
530
- await self.auth()
624
+ try:
625
+ await self.auth()
626
+ except e.BroadlinkException as err:
627
+ _LOGGER.debug(
628
+ "%s: re-authentication failed: %s", self.host[0], err
629
+ )
630
+ return resp
531
631
  async with self._lock:
532
632
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
533
633
  return resp
broadlink/exceptions.py CHANGED
@@ -168,6 +168,6 @@ def exception(err_code: int) -> BroadlinkException:
168
168
 
169
169
  def check_error(error: bytes) -> None:
170
170
  """Raise exception if an error occurred."""
171
- error_code = struct.unpack("h", error)[0]
171
+ error_code = struct.unpack("<h", error)[0]
172
172
  if error_code:
173
173
  raise exception(error_code)
broadlink/remote.py CHANGED
@@ -6,8 +6,9 @@ import logging
6
6
  import struct
7
7
  import time
8
8
  import weakref
9
- from collections.abc import AsyncIterator, Awaitable, Callable
9
+ from collections.abc import AsyncGenerator, Awaitable, Callable
10
10
  from dataclasses import dataclass, field
11
+ from typing import Self
11
12
 
12
13
  from . import exceptions as e
13
14
  from .device import Device
@@ -17,9 +18,10 @@ _LOGGER = logging.getLogger(__name__)
17
18
  TICK = 8192 / 269
18
19
  """Duration of one Broadlink timing unit in microseconds (about 30.45 us).
19
20
 
20
- The RM firmware counts pulses on a 32768 Hz clock (protocol.md: us * 269 / 8192).
21
- Earlier releases used 32.84, the inverse of the right ratio applied the wrong
22
- way round, which compressed externally sourced IR codes by about 7 percent
21
+ The value comes from protocol.md, whose conversion "us * 269 / 8192 works
22
+ very well" was measured against real firmware; 8192/269 is its inverse.
23
+ Earlier releases used 32.84, the right ratio applied the wrong way round,
24
+ which compressed externally sourced IR codes by about 7 percent
23
25
  (mjg59/python-broadlink#839). Codes learned and replayed through the same
24
26
  device were unaffected because both directions shared the constant.
25
27
  """
@@ -53,10 +55,11 @@ class SignalKind(enum.IntEnum):
53
55
 
54
56
  @property
55
57
  def is_rf(self) -> bool:
58
+ """True for the radio bands."""
56
59
  return self is not SignalKind.IR
57
60
 
58
61
  @classmethod
59
- def classify(cls, type_byte: int) -> "SignalKind":
62
+ def classify(cls, type_byte: int) -> Self:
60
63
  """Map a packet's raw first byte to a kind, tolerantly.
61
64
 
62
65
  The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_
@@ -139,7 +142,7 @@ class ParsedPacket:
139
142
 
140
143
  kind: SignalKind
141
144
  repeat: int
142
- pulses: list[int]
145
+ pulses: tuple[int, ...]
143
146
  type_byte: int
144
147
 
145
148
 
@@ -152,7 +155,7 @@ def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket:
152
155
  if len(data) < 4:
153
156
  raise ValueError("Malformed data.")
154
157
  kind = SignalKind.classify(data[0x00])
155
- return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00])
158
+ return ParsedPacket(kind, data[0x01], tuple(data_to_pulses(data, tick)), data[0x00])
156
159
 
157
160
 
158
161
  @dataclass(frozen=True)
@@ -170,11 +173,11 @@ class CapturedSignal:
170
173
 
171
174
  packet: bytes
172
175
  kind: SignalKind
173
- pulses: list[int] = field(repr=False)
176
+ pulses: tuple[int, ...] = field(repr=False)
174
177
  repeat: int = 0
175
178
  frequency_mhz: float | None = None
176
179
  type_byte: int | None = None
177
- captured_at: float = field(default_factory=time.time, repr=False)
180
+ captured_at: float = field(default_factory=time.time, repr=False, compare=False)
178
181
 
179
182
  @classmethod
180
183
  def from_packet(
@@ -183,7 +186,7 @@ class CapturedSignal:
183
186
  frequency_mhz: float | None = None,
184
187
  *,
185
188
  kind: SignalKind | None = None,
186
- ) -> "CapturedSignal":
189
+ ) -> Self:
187
190
  """Build a signal from a device-returned packet.
188
191
 
189
192
  ``kind`` overrides the band read from the packet's type byte. A
@@ -200,7 +203,7 @@ class CapturedSignal:
200
203
  return cls(
201
204
  bytes(packet),
202
205
  kind,
203
- data_to_pulses(packet),
206
+ tuple(data_to_pulses(packet)),
204
207
  packet[0x01],
205
208
  frequency_mhz,
206
209
  type_byte,
@@ -259,6 +262,9 @@ class rmmini(Device):
259
262
  raise e.CaptureInProgressError(
260
263
  "A capture window is already open; close it with aclose() first"
261
264
  )
265
+ if self._window is not prev:
266
+ # Another claimant got in during the two turns above.
267
+ raise e.CaptureInProgressError("A capture window is already open")
262
268
  self._window = new
263
269
 
264
270
  async def _send(self, command: int, data: bytes = b"") -> bytes:
@@ -295,7 +301,7 @@ class rmmini(Device):
295
301
  stop_after_first: bool = True,
296
302
  poll_interval: float = DEFAULT_POLL_INTERVAL,
297
303
  rearm_interval: float = DEFAULT_REARM_INTERVAL,
298
- ) -> AsyncIterator[CapturedSignal]:
304
+ ) -> AsyncGenerator[CapturedSignal]:
299
305
  """Open an infrared capture window and yield what the device hears.
300
306
 
301
307
  The device is put into learning mode and polled every
@@ -343,7 +349,7 @@ class rmmini(Device):
343
349
  frequency_mhz: float | None,
344
350
  *,
345
351
  claim: list | None = None,
346
- ) -> AsyncIterator[CapturedSignal]:
352
+ ) -> AsyncGenerator[CapturedSignal]:
347
353
  # ``claim`` carries a weak reference to this generator (filled in by
348
354
  # the caller after creating it); None means the caller owns the
349
355
  # window claim, as capture_rf does for its inner loop.
@@ -452,7 +458,7 @@ class rmpro(rmmini):
452
458
  stop_after_first: bool = True,
453
459
  poll_interval: float = DEFAULT_POLL_INTERVAL,
454
460
  rearm_interval: float = DEFAULT_REARM_INTERVAL,
455
- ) -> AsyncIterator[CapturedSignal]:
461
+ ) -> AsyncGenerator[CapturedSignal]:
456
462
  """Open a radio frequency capture window and yield what the device hears.
457
463
 
458
464
  With ``frequency`` (in MHz, for example 433.92) the device goes
@@ -491,7 +497,7 @@ class rmpro(rmmini):
491
497
  rearm_interval: float,
492
498
  *,
493
499
  claim: list,
494
- ) -> AsyncIterator[CapturedSignal]:
500
+ ) -> AsyncGenerator[CapturedSignal]:
495
501
  if window < 0 or poll_interval <= 0:
496
502
  raise ValueError("window must be 0 or positive, poll_interval positive")
497
503
  await self._claim_window(claim[0])
broadlink/sensor.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Support for sensors."""
2
2
 
3
- from collections.abc import Sequence
3
+ import struct
4
4
 
5
5
  from . import exceptions as e
6
6
  from .device import Device
@@ -48,35 +48,35 @@ class a2(Device):
48
48
 
49
49
  TYPE = "A2"
50
50
 
51
- async def _send(self, operation: int, data: Sequence = b""):
52
- """Send a command to the device."""
53
- packet = bytearray(12)
54
- packet[0x02] = 0xA5
55
- packet[0x03] = 0xA5
56
- packet[0x04] = 0x5A
57
- packet[0x05] = 0x5A
58
- packet[0x08] = operation
59
- packet[0x09] = 0x0B
60
-
61
- if data:
62
- data_len = len(data)
63
- packet[0x0A] = data_len & 0xFF
64
- packet[0x0B] = data_len >> 8
65
- packet += bytes(2)
66
- packet.extend(data)
67
-
68
- checksum = sum(packet, 0xBEAF) & 0xFFFF
69
- packet[0x06] = checksum & 0xFF
70
- packet[0x07] = checksum >> 8
71
-
72
- packet_len = len(packet) - 2
73
- packet[0x00] = packet_len & 0xFF
74
- packet[0x01] = packet_len >> 8
51
+ async def _send(self, operation: int, data: bytes = b"") -> bytes:
52
+ """Send a command to the device.
53
+
54
+ The frame is the one the SP4 and LB1 families use: a two-byte
55
+ length, the A5A5 5A5A marker, a checksum, the operation, 0x0B and
56
+ a four-byte data length. The 0.19.0 code wrote a two-byte data
57
+ length and a length field two short, and real A2 units answered
58
+ every request with error -5 (mjg59/python-broadlink#826).
59
+ """
60
+ packet = bytearray(14)
61
+ struct.pack_into(
62
+ "<HHHHBBI",
63
+ packet,
64
+ 0,
65
+ 12 + len(data),
66
+ 0xA5A5,
67
+ 0x5A5A,
68
+ 0,
69
+ operation,
70
+ 0x0B,
71
+ len(data),
72
+ )
73
+ packet.extend(data)
74
+ checksum = sum(packet[0x02:], 0xBEAF) & 0xFFFF
75
+ packet[0x06:0x08] = checksum.to_bytes(2, "little")
75
76
 
76
77
  resp = await self.send_packet(0x6A, packet)
77
78
  e.check_error(resp[0x22:0x24])
78
- payload = self.decrypt(resp[0x38:])
79
- return payload
79
+ return self.decrypt(resp[0x38:])
80
80
 
81
81
  async def check_sensors_raw(self) -> dict:
82
82
  """Return the state of the sensors in raw format."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Python API for controlling Broadlink devices
5
5
  Author: DAB-LABS
6
6
  Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
@@ -51,8 +51,9 @@ Every call that reaches a device is a coroutine and must be awaited. That
51
51
  is the main change from the original library's API: method names and
52
52
  arguments are the same, and so are return values, with the small
53
53
  exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data`
54
- returning `bytes`, the unused `Device.lock` attribute removed, and
55
- `timeout` parameters typed as floats).
54
+ returning `bytes`, the unused `Device.lock` attribute removed, `timeout`
55
+ parameters typed as floats, and the `mac` in a hello response typed as
56
+ `bytes`).
56
57
 
57
58
  ```python
58
59
  import asyncio
@@ -81,11 +82,11 @@ The following devices are supported:
81
82
  - **Switches**: MCB1, SC1, SCB1E, SCB2
82
83
  - **Outlets**: BG 800, BG 900
83
84
  - **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2
84
- - **Environment sensors**: A1
85
+ - **Environment sensors**: A1, A2
85
86
  - **Alarm kits**: S1C, S2KIT
86
87
  - **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W
87
88
  - **Curtain motors**: Dooya DT360E-45/20
88
- - **Thermostats**: Hysen HY02B05H
89
+ - **Thermostats**: Hysen HY02/HY03
89
90
  - **Hubs**: S3
90
91
 
91
92
  ## Timing
@@ -192,6 +193,11 @@ If the device is locked, it may not be discoverable with broadcast. In such case
192
193
  device = await broadlink.hello("192.168.0.16")
193
194
  ```
194
195
 
196
+ `discover()` and `hello()` raise `NetworkTimeoutError` when nothing answers
197
+ within the timeout, `socket.gaierror` when a hostname does not resolve, and
198
+ `OSError` when the socket cannot be opened or the send fails (no route, for
199
+ example), the same errors the original library raised from its socket.
200
+
195
201
  If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly:
196
202
  ```python3
197
203
  async for device in broadlink.xdiscover():
@@ -204,6 +210,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
204
210
  await device.auth()
205
211
  ```
206
212
 
213
+ The session key expires on the device after a while. When a request comes
214
+ back with an expired-key answer, the library authenticates again and
215
+ repeats the request once, so a long-running program does not need to
216
+ handle that itself. If the second authentication fails, for example
217
+ because the device was locked in the app in the meantime, the call raises
218
+ the error the device gave the first time, the same `AuthorizationError`
219
+ or `ConnectionClosedError` the original library raised, and it is up to
220
+ the caller to decide what to do. In the worst case one call can wait out
221
+ three timeouts (the request, the authentication, and the repeat), each
222
+ bounded by `device.timeout`.
223
+
207
224
  ### Closing
208
225
 
209
226
  Each device keeps one UDP socket open for its lifetime (the original
@@ -221,9 +238,14 @@ await device.aclose()
221
238
 
222
239
  The socket reopens by itself on the next call, so closing is cheap and
223
240
  safe to do at any time. A request that is in flight when `aclose()` runs
224
- fails with `EndpointClosedError`. An integration that creates devices
225
- should close them when it unloads; a device that is never closed holds
226
- its socket until it is garbage collected.
241
+ fails with `EndpointClosedError`. A request that fails for a network
242
+ reason (a timeout, or an `OSError` from the socket such as "network is
243
+ unreachable" after an interface change, raised at once) also drops the socket, so the
244
+ next call starts fresh rather than reusing one that has gone bad, which
245
+ is how the original library behaved by opening a socket per call. An
246
+ integration that creates devices should close them when it unloads; a
247
+ device that is never closed holds its socket until it is garbage
248
+ collected.
227
249
 
228
250
  The next steps depend on the type of device you want to control.
229
251
 
@@ -299,7 +321,9 @@ By default the window closes after the first signal. Pass
299
321
  because the device holds only one code per learning session. A universal
300
322
  remote has a single receiver, so only one capture window can be open on a
301
323
  device at a time: opening a second one raises `CaptureInProgressError`
302
- while the first is still held. Always close a window you leave early
324
+ while the first is still held, either from the `capture()` call itself or
325
+ from the new window's first iteration, depending on what the first window
326
+ was doing at that moment. Always close a window you leave early
303
327
  (`aclosing` above does it), otherwise it stays open until Python collects
304
328
  the generator.
305
329
 
@@ -1,19 +1,19 @@
1
- broadlink/__init__.py,sha256=ZCQ3wBkaqDYpBsUBPBC0p-2Ys65qSzUe1xBfy9UQEO8,10986
1
+ broadlink/__init__.py,sha256=CYuVoYuKsXTd69yx7X9lIYr3DEPFlUL88bE4rcZMqb4,10890
2
2
  broadlink/alarm.py,sha256=tmeNI-AaPna5nbgoTqz_AGmuQVItGTmt7zyNiRLsSlQ,1260
3
3
  broadlink/climate.py,sha256=fciXwQ0P2_kOaJiXAprfXuDyhLdHU--mE6pCYkVWdF8,15592
4
4
  broadlink/const.py,sha256=Y0XSxYr1k27FKqHWBirJsUB0HGxQ1riIZ9rfVEJaIL4,120
5
5
  broadlink/cover.py,sha256=-wW1BPmf5Z8oeLuqDVEpUyU-PgId5CJ91M3g3RjgVAU,5448
6
- broadlink/device.py,sha256=ZoMV1HnoZRMj4HWLu_NYx5X3eGI9x1571vzpzMCj2jE,20223
7
- broadlink/exceptions.py,sha256=Sc43yKhN8yUD_PBUMXf0eZYUQcsM5daeeMhEzNTk71o,5225
6
+ broadlink/device.py,sha256=-aAZfV_CSWqIeFowewQo5Dn3vvp2YclZ4E19qOc_hVQ,24883
7
+ broadlink/exceptions.py,sha256=S8xOGdzyFGd6ULXUlblDwE-MADoJIR49itEo0j_fZNU,5226
8
8
  broadlink/helpers.py,sha256=Zbw7lNRMB08P5FPAtKMJLExBjts_Gq64tJbR7-JwxX8,1294
9
9
  broadlink/hub.py,sha256=e78lDKcdtDNqSOZkQRqLMPMFR0PgT2g3E7VgULCq-QM,2869
10
10
  broadlink/light.py,sha256=fWCoaf6JmDfcc7ggRmehss9D3cp66evUDlcB50Iu2LI,7660
11
11
  broadlink/protocol.py,sha256=eRCm0fGdApVp5ko_fOP79lazQklda8N6x30ty6P0TiA,1848
12
- broadlink/remote.py,sha256=rdX97BsBtMDWhxLQrGC_yRLaHzrkSzHkD7cA3FjoKGg,22142
13
- broadlink/sensor.py,sha256=dhetprwgv45gkyvHgS2jJrYVuSQ2z-IXJynykgXo_2k,2701
12
+ broadlink/remote.py,sha256=3UjIvyps9BmCCPemGiWCm5BgCsNpT2TjD3dKznQ93JI,22487
13
+ broadlink/sensor.py,sha256=Pa4fJnG28wPFIf7HSN4JQ3cIOfAdTCu5I0spnBG12Qg,2791
14
14
  broadlink/switch.py,sha256=0VurdoGf3CTrYBSQU4ahVSLC_FYHqIRWr3dLkBwIatI,14985
15
- python_broadlink-1.0.2.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
16
- python_broadlink-1.0.2.dist-info/METADATA,sha256=uPKS6KIH1ikvqNGa8m6t7Lg1_WvIvpcSIBZiUd6MPBQ,14321
17
- python_broadlink-1.0.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
- python_broadlink-1.0.2.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
19
- python_broadlink-1.0.2.dist-info/RECORD,,
15
+ python_broadlink-1.0.4.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
16
+ python_broadlink-1.0.4.dist-info/METADATA,sha256=5uZ80FW7Vd0OzTsttoPeI78P0oXJHbLWXRkh92Rb-vE,15819
17
+ python_broadlink-1.0.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ python_broadlink-1.0.4.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
19
+ python_broadlink-1.0.4.dist-info/RECORD,,