python-broadlink 1.0.2__py3-none-any.whl → 1.0.3__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
4
  from collections.abc import AsyncIterator
6
- from typing import Optional, Union
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
@@ -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
@@ -56,10 +56,12 @@ class _Protocol(asyncio.DatagramProtocol):
56
56
  self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
57
57
  self.transport: asyncio.DatagramTransport | None = None
58
58
 
59
- def connection_made(self, transport) -> None: # type: ignore[override]
60
- self.transport = transport
59
+ def connection_made(self, transport: asyncio.BaseTransport) -> None:
60
+ """Keep the transport; the endpoint sends through it."""
61
+ self.transport = transport # type: ignore[assignment]
61
62
 
62
63
  def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
64
+ """Queue every datagram for the request that is waiting."""
63
65
  self.queue.put_nowait((data, addr))
64
66
 
65
67
  def error_received(self, exc: Exception) -> None:
@@ -68,7 +70,7 @@ class _Protocol(asyncio.DatagramProtocol):
68
70
  pass
69
71
 
70
72
  def connection_lost(self, exc: Exception | None) -> None:
71
- pass
73
+ """Nothing to do; a waiting request is told through the queue."""
72
74
 
73
75
  def drain(self) -> None:
74
76
  """Drop anything that arrived before the current request."""
@@ -161,6 +163,17 @@ async def scan(
161
163
  transport.close()
162
164
 
163
165
 
166
+ async def send_setup_packet(
167
+ payload: bytes, ip_address: str, port: int = DEFAULT_PORT
168
+ ) -> None:
169
+ """Broadcast one Wi-Fi provisioning packet to a device in AP mode."""
170
+ transport, _ = await _open_endpoint(broadcast=True)
171
+ try:
172
+ transport.sendto(payload, (ip_address, port))
173
+ finally:
174
+ transport.close()
175
+
176
+
164
177
  async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None:
165
178
  """Send a ping packet to an address.
166
179
 
@@ -213,12 +226,13 @@ class Device:
213
226
  self.aes = None
214
227
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
215
228
 
216
- self._lock: asyncio.Lock | None = None
229
+ self._lock = asyncio.Lock()
217
230
  self._transport: asyncio.DatagramTransport | None = None
218
231
  self._protocol: _Protocol | None = None
219
232
  self._endpoint_addr: tuple[str, int] | None = None
220
233
  self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX)
221
- self._reauth_lock: asyncio.Lock | None = None
234
+ self._reauth_lock = asyncio.Lock()
235
+ self._closes = 0 # Bumped by aclose(); guards an open racing a close.
222
236
  self._auth_generation = 0
223
237
 
224
238
  def __repr__(self) -> str:
@@ -277,9 +291,6 @@ class Device:
277
291
  packet[0x2D] = 0x01
278
292
  packet[0x30:0x36] = b"Test 1"
279
293
 
280
- if self._lock is None:
281
- self._lock = asyncio.Lock()
282
- self._reauth_lock = asyncio.Lock()
283
294
  async with self._lock:
284
295
  self.id = 0
285
296
  self.update_aes(bytes.fromhex(self.__INIT_KEY))
@@ -292,7 +303,7 @@ class Device:
292
303
  _LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id)
293
304
  return True
294
305
 
295
- async def hello(self, local_ip_address=None) -> bool:
306
+ async def hello(self, local_ip_address: str | None = None) -> bool:
296
307
  """Send a hello message to the device.
297
308
 
298
309
  Device information is checked before updating name and lock status.
@@ -385,6 +396,7 @@ class Device:
385
396
  A request in flight fails at once with ``ConnectionClosedError``
386
397
  rather than waiting out its timeout.
387
398
  """
399
+ self._closes += 1
388
400
  transport, protocol = self._transport, self._protocol
389
401
  self._transport = None
390
402
  self._protocol = None
@@ -401,7 +413,15 @@ class Device:
401
413
  # old address, so drop it.
402
414
  await self.aclose()
403
415
  if self._transport is None or self._transport.is_closing():
404
- self._transport, self._protocol = await _open_endpoint(remote_addr=self.host)
416
+ closes = self._closes
417
+ transport, protocol = await _open_endpoint(remote_addr=self.host)
418
+ if self._closes != closes:
419
+ # aclose() ran while the socket was being opened.
420
+ transport.close()
421
+ raise e.EndpointClosedError(
422
+ -4013, "Endpoint closed", "The device endpoint was closed"
423
+ )
424
+ self._transport, self._protocol = transport, protocol
405
425
  self._endpoint_addr = self.host
406
426
  _LOGGER.debug("%s: endpoint opened", self.host[0])
407
427
  return self._transport, self._protocol # type: ignore[return-value]
@@ -507,27 +527,33 @@ class Device:
507
527
  f"No response received within {timeout}s",
508
528
  ) from None
509
529
 
510
- async def send_packet(self, packet_type: int, payload: bytes) -> bytes:
530
+ async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> bytes:
511
531
  """Send a packet to the device and return the raw response frame.
512
532
 
513
533
  If the device answers that the session key is no longer valid, the
514
534
  session is re-authenticated once and the request is sent again.
515
535
  Concurrent callers that hit the same expired key share one
516
- re-authentication and each retry once.
536
+ re-authentication and each retry once. If that re-authentication
537
+ fails (for example the device has been locked in the app), the
538
+ original reply is returned unchanged, so the caller sees the same
539
+ error the original library raised and can run its own recovery.
517
540
  """
518
- if self._lock is None:
519
- self._lock = asyncio.Lock()
520
- self._reauth_lock = asyncio.Lock()
521
- generation = self._auth_generation
522
541
  async with self._lock:
542
+ generation = self._auth_generation
523
543
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
524
544
 
525
545
  code = int.from_bytes(resp[0x22:0x24], "little", signed=True)
526
546
  if code in _REAUTH_CODES:
527
547
  _LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code)
528
- async with self._reauth_lock: # type: ignore[union-attr]
548
+ async with self._reauth_lock:
529
549
  if self._auth_generation == generation:
530
- await self.auth()
550
+ try:
551
+ await self.auth()
552
+ except e.BroadlinkException as err:
553
+ _LOGGER.debug(
554
+ "%s: re-authentication failed: %s", self.host[0], err
555
+ )
556
+ return resp
531
557
  async with self._lock:
532
558
  resp = await self._exchange(self._frame(packet_type, bytes(payload)))
533
559
  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
@@ -8,6 +8,7 @@ import time
8
8
  import weakref
9
9
  from collections.abc import AsyncIterator, 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
@@ -53,10 +54,11 @@ class SignalKind(enum.IntEnum):
53
54
 
54
55
  @property
55
56
  def is_rf(self) -> bool:
57
+ """True for the radio bands."""
56
58
  return self is not SignalKind.IR
57
59
 
58
60
  @classmethod
59
- def classify(cls, type_byte: int) -> "SignalKind":
61
+ def classify(cls, type_byte: int) -> Self:
60
62
  """Map a packet's raw first byte to a kind, tolerantly.
61
63
 
62
64
  The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_
@@ -139,7 +141,7 @@ class ParsedPacket:
139
141
 
140
142
  kind: SignalKind
141
143
  repeat: int
142
- pulses: list[int]
144
+ pulses: tuple[int, ...]
143
145
  type_byte: int
144
146
 
145
147
 
@@ -152,7 +154,7 @@ def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket:
152
154
  if len(data) < 4:
153
155
  raise ValueError("Malformed data.")
154
156
  kind = SignalKind.classify(data[0x00])
155
- return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00])
157
+ return ParsedPacket(kind, data[0x01], tuple(data_to_pulses(data, tick)), data[0x00])
156
158
 
157
159
 
158
160
  @dataclass(frozen=True)
@@ -170,7 +172,7 @@ class CapturedSignal:
170
172
 
171
173
  packet: bytes
172
174
  kind: SignalKind
173
- pulses: list[int] = field(repr=False)
175
+ pulses: tuple[int, ...] = field(repr=False)
174
176
  repeat: int = 0
175
177
  frequency_mhz: float | None = None
176
178
  type_byte: int | None = None
@@ -183,7 +185,7 @@ class CapturedSignal:
183
185
  frequency_mhz: float | None = None,
184
186
  *,
185
187
  kind: SignalKind | None = None,
186
- ) -> "CapturedSignal":
188
+ ) -> Self:
187
189
  """Build a signal from a device-returned packet.
188
190
 
189
191
  ``kind`` overrides the band read from the packet's type byte. A
@@ -200,7 +202,7 @@ class CapturedSignal:
200
202
  return cls(
201
203
  bytes(packet),
202
204
  kind,
203
- data_to_pulses(packet),
205
+ tuple(data_to_pulses(packet)),
204
206
  packet[0x01],
205
207
  frequency_mhz,
206
208
  type_byte,
@@ -259,6 +261,9 @@ class rmmini(Device):
259
261
  raise e.CaptureInProgressError(
260
262
  "A capture window is already open; close it with aclose() first"
261
263
  )
264
+ if self._window is not prev:
265
+ # Another claimant got in during the two turns above.
266
+ raise e.CaptureInProgressError("A capture window is already open")
262
267
  self._window = new
263
268
 
264
269
  async def _send(self, command: int, data: bytes = b"") -> bytes:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-broadlink
3
- Version: 1.0.2
3
+ Version: 1.0.3
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
@@ -204,6 +205,17 @@ After discovering the device, call the `auth()` method to obtain the authenticat
204
205
  await device.auth()
205
206
  ```
206
207
 
208
+ The session key expires on the device after a while. When a request comes
209
+ back with an expired-key answer, the library authenticates again and
210
+ repeats the request once, so a long-running program does not need to
211
+ handle that itself. If the second authentication fails, for example
212
+ because the device was locked in the app in the meantime, the call raises
213
+ the error the device gave the first time, the same `AuthorizationError`
214
+ or `ConnectionClosedError` the original library raised, and it is up to
215
+ the caller to decide what to do. In the worst case one call can wait out
216
+ three timeouts (the request, the authentication, and the repeat), each
217
+ bounded by `device.timeout`.
218
+
207
219
  ### Closing
208
220
 
209
221
  Each device keeps one UDP socket open for its lifetime (the original
@@ -1,19 +1,19 @@
1
- broadlink/__init__.py,sha256=ZCQ3wBkaqDYpBsUBPBC0p-2Ys65qSzUe1xBfy9UQEO8,10986
1
+ broadlink/__init__.py,sha256=wXL3gDxcmE563q4Kjsx9vzWn8Jgopj9Ls7kl4sC0qNQ,10821
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=dP2RbnxZve_m9bJsis51KfDwPNxxJuGapPltYuWF50M,21507
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
12
+ broadlink/remote.py,sha256=1TO3xo_rhp9_Zt8yFRL3Im_IUH9Nbvt9pwyn9gstk18,22418
13
13
  broadlink/sensor.py,sha256=dhetprwgv45gkyvHgS2jJrYVuSQ2z-IXJynykgXo_2k,2701
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.3.dist-info/licenses/LICENSE,sha256=h0N_LWq34pY0W-Ky3ayO6Re5ejG0XvutPlW-GL8eG5I,1111
16
+ python_broadlink-1.0.3.dist-info/METADATA,sha256=YQblobXPiUvcd2rGgxclX3M_JuFMp77kB4d6uSAuJNs,15046
17
+ python_broadlink-1.0.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ python_broadlink-1.0.3.dist-info/top_level.txt,sha256=_Qx7d8oNTOLeDkAN-HHDoq1FlRNlsiaMZlyMWgHXAVc,10
19
+ python_broadlink-1.0.3.dist-info/RECORD,,