python-broadlink 1.0.4__tar.gz → 1.0.6__tar.gz
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.
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/CHANGELOG.md +67 -4
- {python_broadlink-1.0.4/python_broadlink.egg-info → python_broadlink-1.0.6}/PKG-INFO +8 -4
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/README.md +7 -3
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/__init__.py +6 -6
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/device.py +39 -7
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/remote.py +82 -69
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/pyproject.toml +1 -1
- {python_broadlink-1.0.4 → python_broadlink-1.0.6/python_broadlink.egg-info}/PKG-INFO +8 -4
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_loopback.py +17 -1
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_transport.py +53 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/LICENSE +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/MANIFEST.in +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/TROUBLESHOOTING.md +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/alarm.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/climate.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/const.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/cover.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/exceptions.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/helpers.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/hub.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/light.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/protocol.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/sensor.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/broadlink/switch.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/cli/README.md +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/cli/broadlink_cli +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/cli/broadlink_discovery +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/protocol.md +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/SOURCES.txt +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/dependency_links.txt +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/requires.txt +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/top_level.txt +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/setup.cfg +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/__init__.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/__init__.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/cases.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/fixtures.json +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/harness.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/oracle/record.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_capture.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_helpers.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_oracle.py +0 -0
- {python_broadlink-1.0.4 → python_broadlink-1.0.6}/tests/test_remote.py +0 -0
|
@@ -3,6 +3,68 @@
|
|
|
3
3
|
All notable changes to this project are recorded here. The format follows
|
|
4
4
|
Keep a Changelog; versions follow Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## 1.0.6 - 2026-09-06
|
|
7
|
+
|
|
8
|
+
Small items from a sixth review, of 1.0.5, which found no defect in the
|
|
9
|
+
code. No change to the wire format or the public API.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- A network failure during the library's own re-authentication (a timeout,
|
|
14
|
+
or the endpoint being closed) is raised as that failure. 1.0.3 to 1.0.5
|
|
15
|
+
returned the device's original expired-key answer instead, so the caller
|
|
16
|
+
raised `AuthorizationError` for what was really a timeout.
|
|
17
|
+
- An endpoint with no address on either side (discovery, `ping()`,
|
|
18
|
+
`setup()`) is bound to `0.0.0.0` explicitly, for the proactor loop on
|
|
19
|
+
Windows, which starts receiving as soon as the endpoint exists.
|
|
20
|
+
- The RM Max comment and the 1.0.0 changelog entry no longer claim more
|
|
21
|
+
than upstream #838 showed: the mapping follows that pull request's diff,
|
|
22
|
+
and its testers reported the device answering "locked" on
|
|
23
|
+
authentication, so it is listed as reported, not confirmed.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- An undecodable capture is logged at debug rather than warning, since the
|
|
28
|
+
window re-arms and carries on by itself.
|
|
29
|
+
- The RF capture loop closes its inner generator with `aclosing` like the
|
|
30
|
+
IR one.
|
|
31
|
+
|
|
32
|
+
## 1.0.5 - 2026-09-06
|
|
33
|
+
|
|
34
|
+
Fixes from a fifth review, of 1.0.4. No change to the wire format or the
|
|
35
|
+
public API.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- A connected socket can learn from ICMP that a host cannot be reached
|
|
40
|
+
(`EHOSTUNREACH`, typically a router answering for a device that is off),
|
|
41
|
+
and 1.0.4 raised that as an `OSError` at once. The original library's
|
|
42
|
+
unconnected socket never saw it and simply timed out, and Home
|
|
43
|
+
Assistant tolerates a timeout for a few polls where it marks a device
|
|
44
|
+
unavailable on the first `OSError`. Host unreachable, and Windows's
|
|
45
|
+
`ConnectionResetError` for port unreachable, are now treated as silence
|
|
46
|
+
like port unreachable already was: logged, the timeout decides, and the
|
|
47
|
+
socket is still dropped afterwards. Measured on the bench first: on the
|
|
48
|
+
test network neither an on-link address with no host behind it nor an
|
|
49
|
+
off-subnet one produced the ICMP, so this is insurance for networks
|
|
50
|
+
that do, not a fix for one that reproduced.
|
|
51
|
+
- `discover()` closes the `xdiscover()` generator it drains, like
|
|
52
|
+
`hello()` and `xdiscover()` itself.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- The capture window claim is a flag set on the window's first iteration
|
|
57
|
+
and cleared when its generator finishes or is closed, including by
|
|
58
|
+
asyncio's finalizer, in place of the weak reference and frame
|
|
59
|
+
inspection used since 1.0.0. Same behaviour, pinned by the same tests:
|
|
60
|
+
a dropped window gets one turn to be finalized and then blocks nobody,
|
|
61
|
+
a held or paused window is refused to a newcomer. One visible
|
|
62
|
+
difference: `CaptureInProgressError` now always comes from the new
|
|
63
|
+
window's first iteration, never from the `capture()` call itself.
|
|
64
|
+
- A comment next to the RM Max entry says why it sits in `rmpro`
|
|
65
|
+
(upstream #838's text says rm4pro, its tested diff says rmpro).
|
|
66
|
+
- README: a device belongs to the event loop it first talks on.
|
|
67
|
+
|
|
6
68
|
## 1.0.4 - 2026-09-06
|
|
7
69
|
|
|
8
70
|
Fixes from a fourth review, of 1.0.3, which drove the real socket path the
|
|
@@ -278,16 +340,17 @@ history below starts at that fork point.
|
|
|
278
340
|
byte.
|
|
279
341
|
- Devices, carried over from pull requests against the original repository
|
|
280
342
|
with their authors credited (the changes were squash-merged with
|
|
281
|
-
`Co-authored-by` trailers naming each author):
|
|
282
|
-
RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
|
|
343
|
+
`Co-authored-by` trailers naming each author): RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3
|
|
283
344
|
OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802,
|
|
284
345
|
shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805,
|
|
285
346
|
bbcbbk); LEDVANCE SMART+ WIFI CEILING TW 24W 0x6498 (#799, Felipe Martins
|
|
286
347
|
Diel).
|
|
287
348
|
- Devices reported in issues against the original repository, added by
|
|
288
349
|
model name to the existing class for that family and not yet confirmed on
|
|
289
|
-
hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758)
|
|
290
|
-
|
|
350
|
+
hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758), and the RM Max
|
|
351
|
+
0xAF8B from #838 (Alexey Masolov, credited), whose testers reported the
|
|
352
|
+
device answering "locked" on authentication. Please open an issue if any
|
|
353
|
+
of them does not behave.
|
|
291
354
|
- `cryptography` 43 or newer is required, the first release with wheels for
|
|
292
355
|
Python 3.13 (supersedes mjg59/python-broadlink#749).
|
|
293
356
|
- A test suite. The `tests/oracle` package records, for every public method
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.6
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -236,6 +236,11 @@ async with device:
|
|
|
236
236
|
await device.aclose()
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
+
A device belongs to the event loop it first talks on: its socket and its
|
|
240
|
+
locks are bound to that loop, so a script that runs several
|
|
241
|
+
`asyncio.run(...)` calls should create the device inside each one rather
|
|
242
|
+
than reuse it across them.
|
|
243
|
+
|
|
239
244
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
240
245
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
241
246
|
fails with `EndpointClosedError`. A request that fails for a network
|
|
@@ -321,9 +326,8 @@ By default the window closes after the first signal. Pass
|
|
|
321
326
|
because the device holds only one code per learning session. A universal
|
|
322
327
|
remote has a single receiver, so only one capture window can be open on a
|
|
323
328
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
was doing at that moment. Always close a window you leave early
|
|
329
|
+
from the new window's first iteration while the first is still held.
|
|
330
|
+
Always close a window you leave early
|
|
327
331
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
328
332
|
the generator.
|
|
329
333
|
|
|
@@ -205,6 +205,11 @@ async with device:
|
|
|
205
205
|
await device.aclose()
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
+
A device belongs to the event loop it first talks on: its socket and its
|
|
209
|
+
locks are bound to that loop, so a script that runs several
|
|
210
|
+
`asyncio.run(...)` calls should create the device inside each one rather
|
|
211
|
+
than reuse it across them.
|
|
212
|
+
|
|
208
213
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
209
214
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
210
215
|
fails with `EndpointClosedError`. A request that fails for a network
|
|
@@ -290,9 +295,8 @@ By default the window closes after the first signal. Pass
|
|
|
290
295
|
because the device holds only one code per learning session. A universal
|
|
291
296
|
remote has a single receiver, so only one capture window can be open on a
|
|
292
297
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
was doing at that moment. Always close a window you leave early
|
|
298
|
+
from the new window's first iteration while the first is still held.
|
|
299
|
+
Always close a window you leave early
|
|
296
300
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
297
301
|
the generator.
|
|
298
302
|
|
|
@@ -116,6 +116,9 @@ SUPPORTED_TYPES = {
|
|
|
116
116
|
0x27A6: ("RM plus", "Broadlink"),
|
|
117
117
|
0x27A9: ("RM pro+", "Broadlink"),
|
|
118
118
|
0x27C3: ("RM pro+", "Broadlink"),
|
|
119
|
+
# Mapping follows the diff in upstream #838 (its text says rm4pro,
|
|
120
|
+
# its diff says rmpro). Testers in that thread reported the device
|
|
121
|
+
# answering "locked" on auth, so treat it as reported, not confirmed.
|
|
119
122
|
0xAF8B: ("RM Max", "Broadlink"),
|
|
120
123
|
},
|
|
121
124
|
rmminib: {
|
|
@@ -281,12 +284,9 @@ async def discover(
|
|
|
281
284
|
discover_ip_port: int = DEFAULT_PORT,
|
|
282
285
|
) -> list[Device]:
|
|
283
286
|
"""Discover devices connected to the local network."""
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
async for device in
|
|
287
|
-
timeout, local_ip_address, discover_ip_address, discover_ip_port
|
|
288
|
-
)
|
|
289
|
-
]
|
|
287
|
+
devices = xdiscover(timeout, local_ip_address, discover_ip_address, discover_ip_port)
|
|
288
|
+
async with contextlib.aclosing(devices):
|
|
289
|
+
return [device async for device in devices]
|
|
290
290
|
|
|
291
291
|
|
|
292
292
|
async def xdiscover(
|
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import collections
|
|
14
14
|
import contextlib
|
|
15
|
+
import errno
|
|
15
16
|
import logging
|
|
16
17
|
import random
|
|
17
18
|
import socket
|
|
@@ -53,6 +54,22 @@ _QueueItem = tuple[bytes | Exception | None, tuple[str, int] | None]
|
|
|
53
54
|
error the socket reported (address ``None``), or ``_CLOSED``."""
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
def _is_silence(item: object) -> bool:
|
|
58
|
+
"""True for the socket errors that mean "no device answered".
|
|
59
|
+
|
|
60
|
+
A connected datagram socket learns from ICMP that nobody is listening
|
|
61
|
+
(port unreachable, ``ConnectionRefusedError``; ``ConnectionResetError``
|
|
62
|
+
on Windows) or that the host cannot be reached (``EHOSTUNREACH``, from
|
|
63
|
+
a router answering for a host that is off). The original library used
|
|
64
|
+
an unconnected socket that never received any of these and simply
|
|
65
|
+
timed out, and callers such as Home Assistant treat a timeout more
|
|
66
|
+
leniently than an ``OSError``, so these are treated as silence.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(item, ConnectionRefusedError | ConnectionResetError):
|
|
69
|
+
return True
|
|
70
|
+
return isinstance(item, OSError) and item.errno == errno.EHOSTUNREACH
|
|
71
|
+
|
|
72
|
+
|
|
56
73
|
class _Protocol(asyncio.DatagramProtocol):
|
|
57
74
|
"""Datagram protocol that hands every received packet to a queue.
|
|
58
75
|
|
|
@@ -103,7 +120,14 @@ async def _open_endpoint(
|
|
|
103
120
|
remote_addr: tuple[str, int] | None = None,
|
|
104
121
|
broadcast: bool = False,
|
|
105
122
|
) -> tuple[asyncio.DatagramTransport, _Protocol]:
|
|
106
|
-
"""Create a UDP endpoint. Tests replace this to fake the network.
|
|
123
|
+
"""Create a UDP endpoint. Tests replace this to fake the network.
|
|
124
|
+
|
|
125
|
+
An endpoint with no address on either side is bound to ``0.0.0.0``
|
|
126
|
+
explicitly; the proactor loop on Windows starts receiving as soon as the
|
|
127
|
+
endpoint exists, which needs a bound socket.
|
|
128
|
+
"""
|
|
129
|
+
if local_addr is None and remote_addr is None:
|
|
130
|
+
local_addr = ("0.0.0.0", 0)
|
|
107
131
|
loop = asyncio.get_running_loop()
|
|
108
132
|
transport, protocol = await loop.create_datagram_endpoint(
|
|
109
133
|
_Protocol,
|
|
@@ -570,12 +594,15 @@ class Device:
|
|
|
570
594
|
raise e.EndpointClosedError(
|
|
571
595
|
-4013, "Endpoint closed", "The device endpoint was closed"
|
|
572
596
|
)
|
|
573
|
-
if
|
|
574
|
-
# ICMP
|
|
575
|
-
# listening,
|
|
576
|
-
#
|
|
577
|
-
#
|
|
578
|
-
|
|
597
|
+
if _is_silence(resp):
|
|
598
|
+
# ICMP unreachable of one kind or another: the host is up
|
|
599
|
+
# with nothing listening, the device is off or rebooting,
|
|
600
|
+
# or a router answered for it. The original library's
|
|
601
|
+
# unconnected socket never saw these, so keep waiting and
|
|
602
|
+
# let the timeout decide, as it did.
|
|
603
|
+
_LOGGER.debug(
|
|
604
|
+
"%s: unreachable (%s), still waiting", self.host[0], resp
|
|
605
|
+
)
|
|
579
606
|
continue
|
|
580
607
|
if isinstance(resp, Exception):
|
|
581
608
|
# A send failure (no route, address gone) or a fatal
|
|
@@ -623,6 +650,11 @@ class Device:
|
|
|
623
650
|
if self._auth_generation == generation:
|
|
624
651
|
try:
|
|
625
652
|
await self.auth()
|
|
653
|
+
except (e.NetworkTimeoutError, e.EndpointClosedError):
|
|
654
|
+
# A network failure during re-authentication is
|
|
655
|
+
# reported as what it is, not as the device's
|
|
656
|
+
# original expired-key answer.
|
|
657
|
+
raise
|
|
626
658
|
except e.BroadlinkException as err:
|
|
627
659
|
_LOGGER.debug(
|
|
628
660
|
"%s: re-authentication failed: %s", self.host[0], err
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""Support for universal remotes."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
import enum
|
|
5
6
|
import logging
|
|
6
7
|
import struct
|
|
7
8
|
import time
|
|
8
|
-
import weakref
|
|
9
9
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from typing import Self
|
|
@@ -221,51 +221,39 @@ class rmmini(Device):
|
|
|
221
221
|
# against the value it saw when it armed the device and re-arms
|
|
222
222
|
# after any send, since the device has one front end for both.
|
|
223
223
|
self._tx_generation = 0
|
|
224
|
-
#
|
|
225
|
-
# window,
|
|
226
|
-
|
|
224
|
+
# True while a capture window holds the device's receiver. Set by
|
|
225
|
+
# the window on its first iteration, cleared when its generator
|
|
226
|
+
# finishes or is closed, including by asyncio's finalizer.
|
|
227
|
+
self._capturing = False
|
|
227
228
|
|
|
228
229
|
@property
|
|
229
230
|
def capture_active(self) -> bool:
|
|
230
231
|
"""True while a capture window is open on this device."""
|
|
231
|
-
|
|
232
|
-
return window is not None and window.ag_frame is not None
|
|
232
|
+
return self._capturing
|
|
233
233
|
|
|
234
|
-
def
|
|
235
|
-
"""
|
|
236
|
-
old = self._window() if self._window is not None else None
|
|
237
|
-
if old is not None and old.ag_frame is not None and old.ag_running:
|
|
238
|
-
raise e.CaptureInProgressError("A capture window is already open")
|
|
239
|
-
|
|
240
|
-
async def _claim_window(self, new: weakref.ReferenceType) -> None:
|
|
241
|
-
"""Make sure the previous window is really gone, then register ``new``.
|
|
234
|
+
async def _claim_window(self) -> None:
|
|
235
|
+
"""Take the receiver for a new window, or refuse.
|
|
242
236
|
|
|
243
237
|
A consumer that walked away from a window without closing it (for
|
|
244
238
|
example ``break`` out of ``async for`` with no ``aclosing``) leaves
|
|
245
239
|
the generator to asyncio's finalizer, which closes it on the next
|
|
246
|
-
loop iteration once nothing references it
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
window is refused rather than
|
|
240
|
+
loop iteration once nothing references it, and closing it releases
|
|
241
|
+
the claim. Give that a turn. If the claim is still held after that,
|
|
242
|
+
someone still has the window, whether inside ``__anext__`` or
|
|
243
|
+
paused between signals, and the new window is refused rather than
|
|
244
|
+
taken from under them.
|
|
250
245
|
"""
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
raise e.CaptureInProgressError(
|
|
263
|
-
"A capture window is already open; close it with aclose() first"
|
|
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")
|
|
268
|
-
self._window = new
|
|
246
|
+
if self._capturing:
|
|
247
|
+
await asyncio.sleep(0)
|
|
248
|
+
await asyncio.sleep(0)
|
|
249
|
+
if self._capturing:
|
|
250
|
+
raise e.CaptureInProgressError(
|
|
251
|
+
"A capture window is already open; close it with aclose() first"
|
|
252
|
+
)
|
|
253
|
+
self._capturing = True
|
|
254
|
+
|
|
255
|
+
def _release_window(self) -> None:
|
|
256
|
+
self._capturing = False
|
|
269
257
|
|
|
270
258
|
async def _send(self, command: int, data: bytes = b"") -> bytes:
|
|
271
259
|
"""Send a packet to the device."""
|
|
@@ -319,13 +307,11 @@ class rmmini(Device):
|
|
|
319
307
|
Use ``contextlib.aclosing`` (or iterate to the end) so the window is
|
|
320
308
|
released promptly. Only one capture window can be open per device:
|
|
321
309
|
opening one while another is still held raises
|
|
322
|
-
``CaptureInProgressError
|
|
323
|
-
without being closed is finalized by asyncio
|
|
324
|
-
iteration and does not block.
|
|
310
|
+
``CaptureInProgressError`` on its first iteration. A window whose
|
|
311
|
+
generator was dropped without being closed is finalized by asyncio
|
|
312
|
+
on the next loop iteration and does not block.
|
|
325
313
|
"""
|
|
326
|
-
self.
|
|
327
|
-
holder: list = []
|
|
328
|
-
gen = self._capture_loop(
|
|
314
|
+
return self._capture_loop(
|
|
329
315
|
self.enter_learning,
|
|
330
316
|
window,
|
|
331
317
|
stop_after_first,
|
|
@@ -333,10 +319,8 @@ class rmmini(Device):
|
|
|
333
319
|
rearm_interval,
|
|
334
320
|
SignalKind.IR,
|
|
335
321
|
None,
|
|
336
|
-
claim=
|
|
322
|
+
claim=True,
|
|
337
323
|
)
|
|
338
|
-
holder.append(weakref.ref(gen))
|
|
339
|
-
return gen
|
|
340
324
|
|
|
341
325
|
async def _capture_loop(
|
|
342
326
|
self,
|
|
@@ -348,18 +332,43 @@ class rmmini(Device):
|
|
|
348
332
|
kind: SignalKind,
|
|
349
333
|
frequency_mhz: float | None,
|
|
350
334
|
*,
|
|
351
|
-
claim:
|
|
335
|
+
claim: bool,
|
|
352
336
|
) -> AsyncGenerator[CapturedSignal]:
|
|
353
|
-
# ``claim``
|
|
354
|
-
#
|
|
355
|
-
# window claim, as capture_rf does for its inner loop.
|
|
337
|
+
# ``claim`` is False when the caller already holds the window, as
|
|
338
|
+
# capture_rf does for its inner loop.
|
|
356
339
|
if window < 0:
|
|
357
340
|
raise ValueError("window must be 0 (open-ended) or positive")
|
|
358
341
|
if poll_interval <= 0 or rearm_interval <= 0:
|
|
359
342
|
raise ValueError("poll_interval and rearm_interval must be positive")
|
|
360
343
|
if claim:
|
|
361
|
-
await self._claim_window(
|
|
344
|
+
await self._claim_window()
|
|
345
|
+
try:
|
|
346
|
+
body = self._capture_body(
|
|
347
|
+
arm,
|
|
348
|
+
window,
|
|
349
|
+
stop_after_first,
|
|
350
|
+
poll_interval,
|
|
351
|
+
rearm_interval,
|
|
352
|
+
kind,
|
|
353
|
+
frequency_mhz,
|
|
354
|
+
)
|
|
355
|
+
async with contextlib.aclosing(body):
|
|
356
|
+
async for signal in body:
|
|
357
|
+
yield signal
|
|
358
|
+
finally:
|
|
359
|
+
if claim:
|
|
360
|
+
self._release_window()
|
|
362
361
|
|
|
362
|
+
async def _capture_body(
|
|
363
|
+
self,
|
|
364
|
+
arm: Callable[[], Awaitable[None]],
|
|
365
|
+
window: float,
|
|
366
|
+
stop_after_first: bool,
|
|
367
|
+
poll_interval: float,
|
|
368
|
+
rearm_interval: float,
|
|
369
|
+
kind: SignalKind,
|
|
370
|
+
frequency_mhz: float | None,
|
|
371
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
363
372
|
loop = asyncio.get_running_loop()
|
|
364
373
|
deadline = loop.time() + window if window else None
|
|
365
374
|
timeouts = 0
|
|
@@ -399,7 +408,7 @@ class rmmini(Device):
|
|
|
399
408
|
except ValueError as err:
|
|
400
409
|
# A packet the device returned but we cannot decode. Log
|
|
401
410
|
# it, re-arm and keep the window open.
|
|
402
|
-
_LOGGER.
|
|
411
|
+
_LOGGER.debug(
|
|
403
412
|
"%s: ignoring an undecodable capture (%s): %s",
|
|
404
413
|
self.host[0],
|
|
405
414
|
err,
|
|
@@ -475,18 +484,9 @@ class rmpro(rmmini):
|
|
|
475
484
|
Each ``CapturedSignal`` carries the carrier in ``frequency_mhz``,
|
|
476
485
|
which the packet itself does not record.
|
|
477
486
|
"""
|
|
478
|
-
self.
|
|
479
|
-
|
|
480
|
-
gen = self._capture_rf_loop(
|
|
481
|
-
window,
|
|
482
|
-
frequency,
|
|
483
|
-
stop_after_first,
|
|
484
|
-
poll_interval,
|
|
485
|
-
rearm_interval,
|
|
486
|
-
claim=holder,
|
|
487
|
+
return self._capture_rf_loop(
|
|
488
|
+
window, frequency, stop_after_first, poll_interval, rearm_interval
|
|
487
489
|
)
|
|
488
|
-
holder.append(weakref.ref(gen))
|
|
489
|
-
return gen
|
|
490
490
|
|
|
491
491
|
async def _capture_rf_loop(
|
|
492
492
|
self,
|
|
@@ -495,13 +495,28 @@ class rmpro(rmmini):
|
|
|
495
495
|
stop_after_first: bool,
|
|
496
496
|
poll_interval: float,
|
|
497
497
|
rearm_interval: float,
|
|
498
|
-
*,
|
|
499
|
-
claim: list,
|
|
500
498
|
) -> AsyncGenerator[CapturedSignal]:
|
|
501
499
|
if window < 0 or poll_interval <= 0:
|
|
502
500
|
raise ValueError("window must be 0 or positive, poll_interval positive")
|
|
503
|
-
await self._claim_window(
|
|
501
|
+
await self._claim_window()
|
|
502
|
+
try:
|
|
503
|
+
body = self._capture_rf_body(
|
|
504
|
+
window, frequency, stop_after_first, poll_interval, rearm_interval
|
|
505
|
+
)
|
|
506
|
+
async with contextlib.aclosing(body):
|
|
507
|
+
async for signal in body:
|
|
508
|
+
yield signal
|
|
509
|
+
finally:
|
|
510
|
+
self._release_window()
|
|
504
511
|
|
|
512
|
+
async def _capture_rf_body(
|
|
513
|
+
self,
|
|
514
|
+
window: float,
|
|
515
|
+
frequency: float | None,
|
|
516
|
+
stop_after_first: bool,
|
|
517
|
+
poll_interval: float,
|
|
518
|
+
rearm_interval: float,
|
|
519
|
+
) -> AsyncGenerator[CapturedSignal]:
|
|
505
520
|
loop = asyncio.get_running_loop()
|
|
506
521
|
deadline = loop.time() + window if window else None
|
|
507
522
|
|
|
@@ -526,13 +541,11 @@ class rmpro(rmmini):
|
|
|
526
541
|
rearm_interval,
|
|
527
542
|
kind,
|
|
528
543
|
frequency,
|
|
529
|
-
claim=
|
|
544
|
+
claim=False,
|
|
530
545
|
)
|
|
531
|
-
|
|
546
|
+
async with contextlib.aclosing(inner):
|
|
532
547
|
async for signal in inner:
|
|
533
548
|
yield signal
|
|
534
|
-
finally:
|
|
535
|
-
await inner.aclose()
|
|
536
549
|
|
|
537
550
|
async def _sweep(self, deadline: float | None, poll_interval: float) -> float | None:
|
|
538
551
|
"""Sweep for the remote's carrier; return it in MHz, or None if the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-broadlink
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.6
|
|
4
4
|
Summary: Python API for controlling Broadlink devices
|
|
5
5
|
Author: DAB-LABS
|
|
6
6
|
Author-email: Matthew Garrett <mjg59@srcf.ucam.org>
|
|
@@ -236,6 +236,11 @@ async with device:
|
|
|
236
236
|
await device.aclose()
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
+
A device belongs to the event loop it first talks on: its socket and its
|
|
240
|
+
locks are bound to that loop, so a script that runs several
|
|
241
|
+
`asyncio.run(...)` calls should create the device inside each one rather
|
|
242
|
+
than reuse it across them.
|
|
243
|
+
|
|
239
244
|
The socket reopens by itself on the next call, so closing is cheap and
|
|
240
245
|
safe to do at any time. A request that is in flight when `aclose()` runs
|
|
241
246
|
fails with `EndpointClosedError`. A request that fails for a network
|
|
@@ -321,9 +326,8 @@ By default the window closes after the first signal. Pass
|
|
|
321
326
|
because the device holds only one code per learning session. A universal
|
|
322
327
|
remote has a single receiver, so only one capture window can be open on a
|
|
323
328
|
device at a time: opening a second one raises `CaptureInProgressError`
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
was doing at that moment. Always close a window you leave early
|
|
329
|
+
from the new window's first iteration while the first is still held.
|
|
330
|
+
Always close a window you leave early
|
|
327
331
|
(`aclosing` above does it), otherwise it stays open until Python collects
|
|
328
332
|
the generator.
|
|
329
333
|
|
|
@@ -107,4 +107,20 @@ def test_endpoint_heals_after_a_socket_error_on_loopback(caplog):
|
|
|
107
107
|
await dev.aclose()
|
|
108
108
|
|
|
109
109
|
assert asyncio.run(go()) == 9
|
|
110
|
-
assert "
|
|
110
|
+
assert "unreachable" in caplog.text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_unbound_endpoint_is_bound_explicitly():
|
|
114
|
+
"""scan, ping and setup open an endpoint with no address on either
|
|
115
|
+
side; it is bound to 0.0.0.0 so it can receive on every platform."""
|
|
116
|
+
|
|
117
|
+
async def go():
|
|
118
|
+
transport, _ = await device_module._open_endpoint(broadcast=True)
|
|
119
|
+
try:
|
|
120
|
+
host, port = transport.get_extra_info("sockname")[:2]
|
|
121
|
+
return host, port
|
|
122
|
+
finally:
|
|
123
|
+
transport.close()
|
|
124
|
+
|
|
125
|
+
host, port = asyncio.run(go())
|
|
126
|
+
assert host == "0.0.0.0" and port > 0
|
|
@@ -483,6 +483,40 @@ def test_send_failure_fails_the_request_fast_and_heals(net):
|
|
|
483
483
|
assert run(go())
|
|
484
484
|
|
|
485
485
|
|
|
486
|
+
@pytest.mark.parametrize(
|
|
487
|
+
"err",
|
|
488
|
+
[
|
|
489
|
+
ConnectionRefusedError(111, "Connection refused"),
|
|
490
|
+
ConnectionResetError(10054, "Connection reset"),
|
|
491
|
+
OSError(113, "No route to host"),
|
|
492
|
+
],
|
|
493
|
+
ids=["port-unreachable", "windows-reset", "host-unreachable"],
|
|
494
|
+
)
|
|
495
|
+
def test_icmp_unreachable_is_silence_not_an_error(net, err):
|
|
496
|
+
"""0.19.0's unconnected socket never saw ICMP errors: a device that was
|
|
497
|
+
off, rebooting, or behind a router answering for it produced silence
|
|
498
|
+
and then NetworkTimeoutError, which Home Assistant tolerates for a few
|
|
499
|
+
polls where it would mark the device unavailable on an OSError. The
|
|
500
|
+
connected socket must keep that contract."""
|
|
501
|
+
dev = fixed_device()
|
|
502
|
+
dev.timeout = 0.05
|
|
503
|
+
|
|
504
|
+
async def go():
|
|
505
|
+
await dev._endpoint()
|
|
506
|
+
ep = net.endpoints[-1]
|
|
507
|
+
|
|
508
|
+
def icmp_sendto(data, addr=None):
|
|
509
|
+
ep.protocol.error_received(err)
|
|
510
|
+
|
|
511
|
+
ep.sendto = icmp_sendto
|
|
512
|
+
with pytest.raises(e.NetworkTimeoutError):
|
|
513
|
+
await dev.send_packet(0x6A, b"")
|
|
514
|
+
assert ep.closed # dropped after the timeout, so the next call heals
|
|
515
|
+
assert dev._transport is None
|
|
516
|
+
|
|
517
|
+
run(go())
|
|
518
|
+
|
|
519
|
+
|
|
486
520
|
def test_transport_lost_with_error_wakes_the_request(net):
|
|
487
521
|
"""If asyncio closes the transport from its side, the waiting request
|
|
488
522
|
is told instead of waiting out its timeout."""
|
|
@@ -597,6 +631,25 @@ def test_failed_reauth_returns_the_original_reply(net):
|
|
|
597
631
|
e.check_error(resp[0x22:0x24])
|
|
598
632
|
|
|
599
633
|
|
|
634
|
+
def test_timeout_during_reauth_is_reported_as_a_timeout(net):
|
|
635
|
+
"""If the device answers the request with an expired-key code and then
|
|
636
|
+
goes silent during the re-authentication, that is a network failure and
|
|
637
|
+
is raised as one, not returned as the device's original answer."""
|
|
638
|
+
dev = fixed_device()
|
|
639
|
+
dev.timeout = 0.03
|
|
640
|
+
|
|
641
|
+
async def go():
|
|
642
|
+
await dev._endpoint()
|
|
643
|
+
ep = net.endpoints[-1]
|
|
644
|
+
ep.replies = [(make_response(dev, b"", error=0xFFF9), HOST)] # then silence
|
|
645
|
+
with pytest.raises(e.NetworkTimeoutError):
|
|
646
|
+
await dev.send_packet(0x6A, b"")
|
|
647
|
+
return [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent]
|
|
648
|
+
|
|
649
|
+
types = run(go())
|
|
650
|
+
assert types[0] == 0x6A and set(types[1:]) == {0x65} # auth was tried and resent
|
|
651
|
+
|
|
652
|
+
|
|
600
653
|
def test_locked_device_surfaces_as_the_original_error(net):
|
|
601
654
|
"""Device locked in the app: request answered -7, auth answered -1. The
|
|
602
655
|
caller gets the -7 frame back (its check_error raises
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_broadlink-1.0.4 → python_broadlink-1.0.6}/python_broadlink.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|