diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index de98d3df..19057e2c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,10 +1,8 @@ ## Context - [ ] Dependency upgrade @@ -41,15 +38,10 @@ - This PR fixes issue: fixes # - This PR is related to: -- Link to documentation pull request: ## Checklist - -- [ ] The code change is tested and works locally. -- [ ] The code has been formatted using Black. -- [ ] The code follows the [Zen of Python](https://www.python.org/dev/peps/pep-0020/). -- [ ] I am creating the Pull Request against the correct branch. -- [ ] Documentation added/updated. +- [ ] The code change is tested and works locally (`pytest`). +- [ ] `ruff check .` passes. +- [ ] New device support was verified on real hardware, or the PR says it was not. +- [ ] `CHANGELOG.md` has an entry under Unreleased. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ec1ee0c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Test + run: pytest + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build + python -m build + - name: Check the wheel imports + run: | + python -m venv /tmp/check + /tmp/check/bin/pip install dist/*.whl + /tmp/check/bin/python -c "import broadlink; print(broadlink.__name__)" + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/flake8.yaml b/.github/workflows/flake8.yaml deleted file mode 100644 index aa09a19c..00000000 --- a/.github/workflows/flake8.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Python flake8 - -on: - push: - branches: [ master, dev ] - pull_request: - branches: [ master, dev ] - -jobs: - test: - runs-on: ubuntu-20.04 - strategy: - matrix: - python-version: [3.6, 3.7, 3.8, 3.9] - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install wheel - pip install flake8 flake8-quotes - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. ignore magic numbers and use double quotes and ignore numbers with zeroes before them. - # and ignore lowercase hex numbers and ignore isort incorrect imports - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=90 --ignore=WPS432,WPS339,WPS341,I --inline-quotes double --statistics diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..833ab776 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: Publish to PyPI + +# Runs on a version tag (v1.0.0, v1.0.1, ...). Uses PyPI trusted publishing: +# the project on PyPI is configured to trust this repository, this workflow +# file name, and the "pypi" environment. No API token is stored anywhere. + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check the tag matches the package version + run: | + TAG="${GITHUB_REF_NAME#v}" + VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + echo "tag=$TAG version=$VERSION" + test "$TAG" = "$VERSION" + - name: Build + run: | + python -m pip install --upgrade pip build + python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 0d20b648..ef2edb2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,12 @@ *.pyc +__pycache__/ +*.egg-info/ +build/ +dist/ +.venv/ +.pytest_cache/ +.ruff_cache/ +.DS_Store + +# Working notes that are not part of the published project. +docs/internal/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..cc1a750d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,90 @@ +# Changelog + +All notable changes to this project are recorded here. The format follows +Keep a Changelog; versions follow Semantic Versioning. + +## Unreleased + +This is the first release of `python-broadlink`, a maintained fork of +`mjg59/python-broadlink` (PyPI `broadlink`, last released as 0.19.0). The +history below starts at that fork point. + +### Changed + +- **The library is asynchronous.** Every method that talks to a device is + now a coroutine: `await device.auth()`, `await device.send_data(...)`, + `await device.check_sensors()`, and so on. Discovery is + `await broadlink.discover(...)`, `broadlink.hello(...)` and `setup(...)` + are coroutines, and `xdiscover(...)` is an async generator. The packet + helpers (`pulses_to_data`, `data_to_pulses`), CRC and datetime helpers + stay synchronous. There is no synchronous compatibility layer: a call + without `await` returns a coroutine and does nothing. +- Each device keeps one UDP endpoint for its lifetime (the previous + version opened a socket per call) and serializes requests on it with an + `asyncio.Lock`. The old code declared a lock but never acquired it. + `async with device:` or `await device.aclose()` releases the endpoint; + it reopens on the next call. +- When a device reports that the session key has expired, the library + re-authenticates once and repeats the request. Callers no longer need + their own re-auth loop. +- Retry and timeout behaviour is unchanged: a request is repeated every + second until `timeout` elapses, then `NetworkTimeoutError` is raised. +- `dooya.set_percentage_and_wait` sleeps with `asyncio.sleep`. +- The CLI tools run their body under `asyncio.run`. +- Packaging moved to `pyproject.toml`; `setup.py` and the stale + `requirements.txt` pin are gone. The distribution name is now + `python-broadlink`; the import name stays `broadlink`. Python 3.13 or + newer is required. +- Continuous integration now runs `ruff` and `pytest` on Python 3.13 and + 3.14, and builds the sdist and wheel on every pull request. Releases are + published to PyPI from version tags using trusted publishing. + +### Fixed + +- The IR tick constant used by `pulses_to_data` and `data_to_pulses` is now + `TICK = 8192 / 269` (about 30.45 us), matching the device's 32768 Hz + timebase as documented in `protocol.md`. The previous value, 32.84, was + the inverse ratio applied the wrong way round and compressed IR codes + built from true microsecond timings by about 7 percent. Codes learned and + replayed through the same device were unaffected. Verified on an RM4 Pro + against an independent receiver in both directions. + (mjg59/python-broadlink#839, #841) +- `pulses_to_data` rounds each duration to the nearest tick instead of + truncating, which removes up to one tick of systematic shortening per + pulse. + +### Added + +- `capture()` and `capture_rf()`, async generators that own the arm, poll, + timeout and re-arm loop of a learning session and yield each signal as a + `CapturedSignal` (device packet, decoded pulses at the correct tick, + kind, repeat count, and for RF the carrier frequency). They re-arm on a + timer, because the device leaves learning mode silently, and after any + `send_data`, because a transmission ends the session; both intervals and + the poll cadence were set from a bench on an RM4 Pro. Only one window can + be open per device. `capture_rf()` (Pro models only) takes the carrier + frequency directly and falls back to the on-device sweep when it is not + given. +- Packet helpers: `pulses_to_data` takes `kind` and `repeat`, `parse_packet` + is its inverse, and `SignalKind` names the IR, 433 MHz and 315 MHz bands. + A device's returned RF packet does not always use the canonical type byte + (an RM4 Pro answers a 433 MHz capture with 0xB1, not 0xB2), so the kind is + read by band and a capture is tagged from what it armed rather than the + byte. +- Devices, carried over from pull requests against the original repository + with their authors' commits intact: RM Max 0xAF8B (#838, Alexey Masolov); + RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3 + OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802, + shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805, + bbcbbk); LEDVANCE SMART+ WIFI CEILING TW 24W 0x6498 (#799, Felipe Martins + Diel). +- Devices reported in issues against the original repository, added by + model name to the existing class for that family and not yet confirmed on + hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758). Please open an + issue if either does not behave. +- `cryptography` 43 or newer is required, the first release with wheels for + Python 3.13 (supersedes mjg59/python-broadlink#749). +- A test suite. The `tests/oracle` package records the exact request bytes + every public method of every device class sends, and the results it + decodes from canned responses, so that later changes to the transport + can be checked byte for byte against the original behavior. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..2422af09 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include LICENSE README.md CHANGELOG.md protocol.md TROUBLESHOOTING.md +include pyproject.toml +graft cli +graft tests +global-exclude __pycache__ *.py[cod] .DS_Store diff --git a/README.md b/README.md index 81c6de5b..6d5d4d5f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,53 @@ # python-broadlink -A Python module and CLI for controlling Broadlink devices locally. The following devices are supported: +A Python module and CLI for controlling Broadlink devices locally. + +> **About this fork.** This repository is a maintained fork of +> [mjg59/python-broadlink](https://github.com/mjg59/python-broadlink), which +> has not accepted changes since 2024. It exists so that Home Assistant's +> Broadlink integration has a library that can take fixes and new devices. +> The distribution on PyPI is `python-broadlink`; the import name stays +> `broadlink`. The first release corrects the IR timing constant reported in +> upstream [#839](https://github.com/mjg59/python-broadlink/issues/839) +> (fix in [#841](https://github.com/mjg59/python-broadlink/pull/841)) and +> adds the devices waiting in upstream's pull request queue, including the +> RM Max and RM5 Plus. Version 1.0 will be asynchronous; see `CHANGELOG.md`. +> Upstream's credit and MIT license are preserved. + +## Version 1.0 is asynchronous + +Every call that reaches a device is a coroutine and must be awaited. This +is the whole change from the original library's API; method names, +arguments and return values are the same. + +```python +import asyncio +import broadlink + +async def main(): + devices = await broadlink.discover(timeout=5) + device = devices[0] + await device.auth() + print(await device.check_sensors()) + +asyncio.run(main()) +``` + +Calling a device method without `await` returns a coroutine object and +sends nothing; Python prints a `RuntimeWarning: coroutine ... was never +awaited` when it is garbage collected. If you need the old synchronous +behaviour, pin the original distribution (`broadlink==0.19.0`) instead. + +The following devices are supported: -- **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate -- **Smart plugs**: SP mini, SP mini 3, SP mini+, SP1, SP2, SP2-BR, SP2-CL, SP2-IN, SP2-UK, SP3, SP3-EU, SP3S-EU, SP3S-US, SP4L-AU, SP4L-EU, SP4L-UK, SP4M, SP4M-US, Ankuoo NEO, Ankuoo NEO PRO, Efergy Ego, BG AHC/U-01 +- **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate, RM Max, RM5 plus +- **Smart plugs**: SP mini, SP mini 3, SP mini+, SP1, SP2, SP2-BR, SP2-CL, SP2-IN, SP2-UK, SP3, SP3-EU, SP3S-EU, SP3S-US, SP4L-AU, SP4L-EU, SP4L-UK, SP4M, SP4M-US, SP mini 3-AL, Ankuoo NEO, Ankuoo NEO PRO, Efergy Ego, BG AHC/U-01 - **Switches**: MCB1, SC1, SCB1E, SCB2 - **Outlets**: BG 800, BG 900 - **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2 - **Environment sensors**: A1 - **Alarm kits**: S1C, S2KIT -- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD +- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W - **Curtain motors**: Dooya DT360E-45/20 - **Thermostats**: Hysen HY02B05H - **Hubs**: S3 @@ -19,16 +57,20 @@ A Python module and CLI for controlling Broadlink devices locally. The following Use pip3 to install the latest version of this module. ``` -pip3 install broadlink +pip3 install python-broadlink ``` +If the original `broadlink` distribution is also installed in the same +environment, remove it first (`pip3 uninstall broadlink`); both provide the +`broadlink` package. + ## Basic functions -First, open Python 3 and import this module. +The examples below are written as they would appear inside an `async def` +function run with `asyncio.run(...)`, as in the snippet above. To try them +interactively, start Python with `python3 -m asyncio`, which gives you a +prompt where `await` works at the top level. -``` -python3 -``` ```python3 import broadlink ``` @@ -45,7 +87,7 @@ In order to control the device, you need to connect it to your local network. If - Manually connect to the WiFi SSID named BroadlinkProv. 2. Connect the device to your local network with the setup function. ```python3 -broadlink.setup('myssid', 'mynetworkpass', 3) +await broadlink.setup('myssid', 'mynetworkpass', 3) ``` Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2) @@ -54,7 +96,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2) You may need to specify a broadcast address if setup is not working. ```python3 -broadlink.setup('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255') +await broadlink.setup('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255') ``` ### Discovery @@ -62,7 +104,7 @@ broadlink.setup('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255') Use this function to discover devices: ```python3 -devices = broadlink.discover() +devices = await broadlink.discover() ``` #### Advanced options @@ -70,29 +112,29 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery Using the IP address of your local machine: ```python3 -devices = broadlink.discover(local_ip_address='192.168.0.100') +devices = await broadlink.discover(local_ip_address='192.168.0.100') ``` Using the broadcast address of your subnet: ```python3 -devices = broadlink.discover(discover_ip_address='192.168.0.255') +devices = await broadlink.discover(discover_ip_address='192.168.0.255') ``` If the device is locked, it may not be discoverable with broadcast. In such cases, you can use the unicast version `broadlink.hello()` for direct discovery: ```python3 -device = broadlink.hello('192.168.0.16') +device = await broadlink.hello('192.168.0.16') ``` If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly: ```python3 -for device in broadlink.xdiscover(): +async for device in broadlink.xdiscover(): print(device) # Example action. Do whatever you want here. ``` ### Authentication After discovering the device, call the `auth()` method to obtain the authentication key required for further communication: ```python3 -device.auth() +await device.auth() ``` The next steps depend on the type of device you want to control. @@ -105,12 +147,12 @@ Learning IR codes takes place in three steps. 1. Enter learning mode: ```python3 -device.enter_learning() +await device.enter_learning() ``` 2. When the LED blinks, point the remote at the Broadlink device and press the button you want to learn. 3. Get the IR packet. ```python3 -packet = device.check_data() +packet = await device.check_data() ``` ### Learning RF codes @@ -119,7 +161,7 @@ Learning RF codes takes place in six steps. 1. Sweep the frequency: ```python3 -device.sweep_frequency() +await device.sweep_frequency() ``` 2. When the LED blinks, point the remote at the Broadlink device for the first time and long press the button you want to learn. 3. Check if the frequency was successfully identified: @@ -130,12 +172,12 @@ if ok: ``` 4. Enter learning mode: ```python3 -device.find_rf_packet() +await device.find_rf_packet() ``` 5. When the LED blinks, point the remote at the Broadlink device for the second time and short press the button you want to learn. 6. Get the RF packet: ```python3 -packet = device.check_data() +packet = await device.check_data() ``` #### Notes @@ -146,25 +188,65 @@ Universal remotes with product id 0x2712 use the same method for learning IR and You can exit the learning mode in the middle of the process by calling this method: ```python3 -device.cancel_sweep_frequency() +await device.cancel_sweep_frequency() +``` + +### Capturing signals + +`capture()` wraps the arm, poll, timeout and re-arm dance above into one +async generator that yields each signal it hears as a `CapturedSignal`: + +```python3 +from contextlib import aclosing + +async with aclosing(device.capture(window=30)) as signals: + async for signal in signals: + print(signal.kind, len(signal.pulses), "pulses") + await other_device.send_data(signal.packet) ``` +By default the window closes after the first signal. Pass +`stop_after_first=False` to keep it open for the whole `window` (in seconds; +`window=0` runs until the generator is closed), re-arming after each signal +because the device holds only one code per learning session. A universal +remote has a single receiver, so only one capture window can be open on a +device at a time. + +`CapturedSignal` carries the device's own `packet` bytes (ready for +`send_data`), the decoded `pulses` in microseconds at the correct tick, the +`kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for +RF the `frequency_mhz` the packet itself does not record. + +RF works the same way on the Pro models, with the carrier as the one extra +input: + +```python3 +async with aclosing(device.capture_rf(window=30, frequency=433.92)) as signals: + async for signal in signals: + ... +``` + +Pass `frequency` whenever you know it. Without it the device first sweeps +for the carrier while you hold a button down, then learns the code from a +fresh press; the sweep is unreliable on some firmware and can report a +carrier it never really locked, so the known-frequency path is preferred. + ### Sending IR/RF packets ```python3 -device.send_data(packet) +await device.send_data(packet) ``` ### Fetching sensor data ```python3 -data = device.check_sensors() +data = await device.check_sensors() ``` ## Switches ### Setting power state ```python3 -device.set_power(True) -device.set_power(False) +await device.set_power(True) +await device.set_power(False) ``` ### Checking power state @@ -181,8 +263,8 @@ state = device.get_energy() ### Setting power state ```python3 -device.set_power(1, True) # Example socket. It could be 2 or 3. -device.set_power(1, False) +await device.set_power(1, True) # Example socket. It could be 2 or 3. +await device.set_power(1, False) ``` ### Checking power state @@ -199,35 +281,35 @@ state = device.get_state() ### Setting state attributes ```python3 -devices[0].set_state(pwr=0) -devices[0].set_state(pwr=1) -devices[0].set_state(brightness=75) -devices[0].set_state(bulb_colormode=0) -devices[0].set_state(blue=255) -devices[0].set_state(red=0) -devices[0].set_state(green=128) -devices[0].set_state(bulb_colormode=1) +await devices[0].set_state(pwr=0) +await devices[0].set_state(pwr=1) +await devices[0].set_state(brightness=75) +await devices[0].set_state(bulb_colormode=0) +await devices[0].set_state(blue=255) +await devices[0].set_state(red=0) +await devices[0].set_state(green=128) +await devices[0].set_state(bulb_colormode=1) ``` ## Environment sensors ### Fetching sensor data ```python3 -data = device.check_sensors() +data = await device.check_sensors() ``` ## Hubs ### Discovering subdevices ```python3 -device.get_subdevices() +await device.get_subdevices() ``` ### Fetching data Use the DID obtained from get_subdevices() for the input parameter to query specific sub-device. ```python3 -device.get_state(did="00000000000000000000a043b0d06963") +await device.get_state(did="00000000000000000000a043b0d06963") ``` ### Setting state attributes @@ -235,13 +317,13 @@ The parameters depend on the type of subdevice that is being controlled. In this #### Turn on ```python3 -device.set_state(did="00000000000000000000a043b0d0783a", pwr=1) -device.set_state(did="00000000000000000000a043b0d0783a", pwr1=1) -device.set_state(did="00000000000000000000a043b0d0783a", pwr2=1) +await device.set_state(did="00000000000000000000a043b0d0783a", pwr=1) +await device.set_state(did="00000000000000000000a043b0d0783a", pwr1=1) +await device.set_state(did="00000000000000000000a043b0d0783a", pwr2=1) ``` #### Turn off ```python3 -device.set_state(did="00000000000000000000a043b0d0783a", pwr=0) -device.set_state(did="00000000000000000000a043b0d0783a", pwr1=0) -device.set_state(did="00000000000000000000a043b0d0783a", pwr2=0) +await device.set_state(did="00000000000000000000a043b0d0783a", pwr=0) +await device.set_state(did="00000000000000000000a043b0d0783a", pwr1=0) +await device.set_state(did="00000000000000000000a043b0d0783a", pwr2=0) ``` diff --git a/broadlink/__init__.py b/broadlink/__init__.py index d3135501..eab43e72 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 """The python-broadlink library.""" -import socket -from typing import Generator, List, Optional, Tuple, Union +from collections.abc import AsyncIterator +from typing import List, Optional, Tuple, Union from . import exceptions as e -from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT from .alarm import S1C from .climate import hvac, hysen +from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT from .cover import dooya, dooya2, wser -from .device import Device, ping, scan +from .device import Device, _open_endpoint, ping, scan from .hub import s3 from .light import lb1, lb2 -from .remote import rm, rm4, rm4mini, rm4pro, rmmini, rmminib, rmpro +from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro from .sensor import a1, a2 from .switch import bg1, ehc31, mp1, mp1s, sp1, sp2, sp2s, sp3, sp3s, sp4, sp4b @@ -63,6 +63,7 @@ 0x7583: ("SP mini 3", "Broadlink"), 0x7587: ("SP4L-UK", "Broadlink"), 0x7D11: ("SP mini 3", "Broadlink"), + 0x7D15: ("SP mini 3-AL", "Broadlink (OEM)"), 0xA4F9: ("WS4", "Broadlink (OEM)"), 0xA569: ("SP4L-UK", "Broadlink"), 0xA56A: ("MCB1", "Broadlink"), @@ -71,6 +72,7 @@ 0xA576: ("SP4L-AU", "Broadlink"), 0xA589: ("SP4L-UK", "Broadlink"), 0xA5D3: ("SP4L-EU", "Broadlink"), + 0xA57A: ("SP4", "Broadlink"), 0xA6F4: ("SP4D-US", "Broadlink"), }, sp4b: { @@ -90,6 +92,7 @@ 0x27B7: ("RM mini 3", "Broadlink"), 0x27C2: ("RM mini 3", "Broadlink"), 0x27C7: ("RM mini 3", "Broadlink"), + 0x27C8: ("RM mini 3", "Broadlink"), # CMCC version 0x27CC: ("RM mini 3", "Broadlink"), 0x27CD: ("RM mini 3", "Broadlink"), 0x27D0: ("RM mini 3", "Broadlink"), @@ -97,6 +100,7 @@ 0x27D3: ("RM mini 3", "Broadlink"), 0x27DC: ("RM mini 3", "Broadlink"), 0x27DE: ("RM mini 3", "Broadlink"), + 0xA544: ("RM mini 3", "Broadlink (OEM)"), }, rmpro: { 0x2712: ("RM pro/pro+", "Broadlink"), @@ -112,6 +116,7 @@ 0x27A6: ("RM plus", "Broadlink"), 0x27A9: ("RM pro+", "Broadlink"), 0x27C3: ("RM pro+", "Broadlink"), + 0xAF8B: ("RM Max", "Broadlink"), }, rmminib: { 0x5F36: ("RM mini 3", "Broadlink"), @@ -147,6 +152,9 @@ 0x649B: ("RM4 pro", "Broadlink"), 0x653C: ("RM4 pro", "Broadlink"), }, + rm5plus: { + 0x5224: ("RM5 plus", "Broadlink"), + }, a1: { 0x2714: ("A1", "Broadlink"), }, @@ -155,6 +163,7 @@ }, mp1: { 0x4EB5: ("MP1-1K4S", "Broadlink"), + 0x4EDA: ("MP1-1K3S2U", "Broadlink"), 0x4F1B: ("MP1-1K3S2U", "Broadlink (OEM)"), 0x4F65: ("MP1-1K3S2U", "Broadlink"), }, @@ -173,11 +182,13 @@ 0x644C: ("LB27 R1", "Broadlink"), 0x644E: ("LB26 R1", "Broadlink"), 0x6488: ("LB27 C1", "Broadlink"), + 0x6498: ("SMART+ WIFI CEILING TW 24W", "LEDVANCE"), }, lb2: { 0xA4F4: ("LB27 R1", "Broadlink"), 0xA5F7: ("LB27 R1", "Broadlink"), 0xA6EF: ("EFCF60WSMT", "Luceco"), + 0xA517: ("LB26 R1", "Broadlink"), }, S1C: { 0x2722: ("S2KIT", "Broadlink"), @@ -238,64 +249,62 @@ def gendevice( return Device(host, mac, dev_type, name=name, is_locked=is_locked) -def hello( +async def hello( ip_address: str, port: int = DEFAULT_PORT, - timeout: int = DEFAULT_TIMEOUT, + timeout: float = DEFAULT_TIMEOUT, ) -> Device: """Direct device discovery. Useful if the device is locked. """ - try: - return next( - xdiscover( - timeout=timeout, - discover_ip_address=ip_address, - discover_ip_port=port, - ) - ) - except StopIteration as err: - raise e.NetworkTimeoutError( - -4000, - "Network timeout", - f"No response received within {timeout}s", - ) from err + async for device in xdiscover( + timeout=timeout, + discover_ip_address=ip_address, + discover_ip_port=port, + ): + return device + raise e.NetworkTimeoutError( + -4000, + "Network timeout", + f"No response received within {timeout}s", + ) -def discover( - timeout: int = DEFAULT_TIMEOUT, +async def discover( + timeout: float = DEFAULT_TIMEOUT, local_ip_address: Optional[str] = None, discover_ip_address: str = DEFAULT_BCAST_ADDR, discover_ip_port: int = DEFAULT_PORT, ) -> List[Device]: """Discover devices connected to the local network.""" - responses = scan( - timeout, local_ip_address, discover_ip_address, discover_ip_port - ) - return [gendevice(*resp) for resp in responses] + return [ + device + async for device in xdiscover( + timeout, local_ip_address, discover_ip_address, discover_ip_port + ) + ] -def xdiscover( - timeout: int = DEFAULT_TIMEOUT, +async def xdiscover( + timeout: float = DEFAULT_TIMEOUT, local_ip_address: Optional[str] = None, discover_ip_address: str = DEFAULT_BCAST_ADDR, discover_ip_port: int = DEFAULT_PORT, -) -> Generator[Device, None, None]: +) -> AsyncIterator[Device]: """Discover devices connected to the local network. - This function returns a generator that yields devices instantly. + Yields each device as soon as it answers. """ - responses = scan( + async for resp in scan( timeout, local_ip_address, discover_ip_address, discover_ip_port - ) - for resp in responses: + ): yield gendevice(*resp) # Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode. # Only tested with Broadlink RM3 Mini (Blackbean) -def setup( +async def setup( ssid: str, password: str, security_mode: int, @@ -326,8 +335,8 @@ def setup( payload[0x20] = checksum & 0xFF # Checksum 1 position payload[0x21] = checksum >> 8 # Checksum 2 position - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Internet # UDP - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.sendto(payload, (ip_address, DEFAULT_PORT)) - sock.close() + transport, _ = await _open_endpoint(broadcast=True) + try: + transport.sendto(payload, (ip_address, DEFAULT_PORT)) + finally: + transport.close() diff --git a/broadlink/alarm.py b/broadlink/alarm.py index a9b5e879..2c3358de 100644 --- a/broadlink/alarm.py +++ b/broadlink/alarm.py @@ -14,11 +14,11 @@ class S1C(Device): 0x21: "Motion Sensor", } - def get_sensors_status(self) -> dict: + async def get_sensors_status(self) -> dict: """Return the state of the sensors.""" packet = bytearray(16) packet[0] = 0x06 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) count = payload[0x4] diff --git a/broadlink/climate.py b/broadlink/climate.py old mode 100755 new mode 100644 index 1a0c6006..5d75457d --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -21,14 +21,14 @@ class hysen(Device): TYPE = "HYS" - def send_request(self, request: Sequence[int]) -> bytes: + async def send_request(self, request: Sequence[int]) -> bytes: """Send a request to the device.""" packet = bytearray() packet.extend((len(request) + 2).to_bytes(2, "little")) packet.extend(request) packet.extend(CRC16.calculate(request).to_bytes(2, "little")) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) @@ -52,22 +52,22 @@ def _decode_temp(self, payload, base_index): offset = (offset_raw_value + 1) / 10 if add_offset else 0.0 return base_temp + offset - def get_temp(self) -> float: + async def get_temp(self) -> float: """Return the room temperature in degrees celsius.""" - payload = self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x08]) + payload = await self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x08]) return self._decode_temp(payload, 5) - def get_external_temp(self) -> float: + async def get_external_temp(self) -> float: """Return the external temperature in degrees celsius.""" - payload = self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x08]) + payload = await self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x08]) return self._decode_temp(payload, 18) - def get_full_status(self) -> dict: + async def get_full_status(self) -> dict: """Return the state of the device. Timer schedule included. """ - payload = self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x16]) + payload = await self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x16]) data = {} data["remote_lock"] = payload[3] & 1 data["power"] = payload[4] & 1 @@ -127,12 +127,12 @@ def get_full_status(self) -> dict: # E.g. loop_mode = 0 ("12345,67") means Saturday and Sunday (weekend schedule) # loop_mode = 2 ("1234567") means every day, including Saturday and Sunday (weekday schedule) # The sensor command is currently experimental - def set_mode( + async def set_mode( self, auto_mode: int, loop_mode: int, sensor: int = 0 ) -> None: """Set the mode of the device.""" mode_byte = ((loop_mode + 1) << 4) + auto_mode - self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor]) + await self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor]) # Advanced settings # Sensor mode (SEN) sensor = 0 for internal sensor, 1 for external sensor, @@ -145,7 +145,7 @@ def set_mode( # Anti-freezing function (FrE) fre = 0 for anti-freezing function shut down, # 1 for anti-freezing function open. Factory default: 0 # Power on memory (POn) poweron = 0 for off, 1 for on. Default: 0 - def set_advanced( + async def set_advanced( self, loop_mode: int, sensor: int, @@ -158,7 +158,7 @@ def set_advanced( poweron: int, ) -> None: """Set advanced options.""" - self.send_request( + await self.send_request( [ 0x01, 0x10, @@ -182,34 +182,34 @@ def set_advanced( # For backwards compatibility only. Prefer calling set_mode directly. # Note this function invokes loop_mode=0 and sensor=0. - def switch_to_auto(self) -> None: + async def switch_to_auto(self) -> None: """Switch mode to auto.""" - self.set_mode(auto_mode=1, loop_mode=0) + await self.set_mode(auto_mode=1, loop_mode=0) - def switch_to_manual(self) -> None: + async def switch_to_manual(self) -> None: """Switch mode to manual.""" - self.set_mode(auto_mode=0, loop_mode=0) + await self.set_mode(auto_mode=0, loop_mode=0) # Set temperature for manual mode (also activates manual mode if currently in automatic) - def set_temp(self, temp: float) -> None: + async def set_temp(self, temp: float) -> None: """Set the target temperature.""" - self.send_request([0x01, 0x06, 0x00, 0x01, 0x00, int(temp * 2)]) + await self.send_request([0x01, 0x06, 0x00, 0x01, 0x00, int(temp * 2)]) # Set device on(1) or off(0), does not deactivate Wifi connectivity. # Remote lock disables control by buttons on thermostat. # heating_cooling: heating(0) cooling(1) - def set_power( + async def set_power( self, power: int = 1, remote_lock: int = 0, heating_cooling: int = 0 ) -> None: """Set the power state of the device.""" state = (heating_cooling << 7) + power - self.send_request([0x01, 0x06, 0x00, 0x00, remote_lock, state]) + await self.send_request([0x01, 0x06, 0x00, 0x00, remote_lock, state]) # set time on device # n.b. day=1 is Monday, ..., day=7 is Sunday - def set_time(self, hour: int, minute: int, second: int, day: int) -> None: + async def set_time(self, hour: int, minute: int, second: int, day: int) -> None: """Set the time.""" - self.send_request( + await self.send_request( [ 0x01, 0x10, @@ -231,7 +231,7 @@ def set_time(self, hour: int, minute: int, second: int, day: int) -> None: # {'start_hour':17, 'start_minute':30, 'temp': 22 } # Each one specifies the thermostat temp that will become effective at start_hour:start_minute # weekend is similar but only has 2 (e.g. switch on in morning and off in afternoon) - def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None: + async def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None: """Set timer schedule.""" request = [0x01, 0x10, 0x00, 0x0A, 0x00, 0x0C, 0x18] @@ -253,7 +253,7 @@ def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None: for i in range(0, 2): request.append(int(weekend[i]["temp"] * 2)) - self.send_request(request) + await self.send_request(request) class hvac(Device): @@ -343,11 +343,11 @@ def _decode(self, response: bytes) -> bytes: d_len = int.from_bytes(payload[0x08:0x0A], "little") return payload[0x0A:0x0A+d_len] - def _send(self, command: int, data: bytes = b"") -> bytes: + async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a command to the unit.""" prefix = bytes([((command << 4) | 1), 1]) packet = self._encode(prefix + data) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response)[0x02:] @@ -369,7 +369,7 @@ def _parse_state(self, data: bytes) -> dict: state["mildew"] = bool(data[0x0A] & 1 << 3) return state - def set_state( + async def set_state( self, power: bool, target_temp: float, # 16<=target_temp<=32 @@ -414,10 +414,10 @@ def set_state( data[0x0A] = display << 4 | mildew << 3 data[0x0C] = UNK2 - resp = self._send(0, data) + resp = await self._send(0, data) return self._parse_state(resp) - def get_state(self) -> dict: + async def get_state(self) -> dict: """Returns a dictionary with the unit's parameters. Returns: @@ -436,7 +436,7 @@ def get_state(self) -> dict: clean (bool): mildew (bool): """ - resp = self._send(1) + resp = await self._send(1) if len(resp) < 13: raise e.DataValidationError( @@ -447,7 +447,7 @@ def get_state(self) -> dict: return self._parse_state(resp) - def get_ac_info(self) -> dict: + async def get_ac_info(self) -> dict: """Returns dictionary with AC info. Returns: @@ -455,7 +455,7 @@ def get_ac_info(self) -> dict: power (bool): power ambient_temp (float): ambient temperature """ - resp = self._send(2) + resp = await self._send(2) if len(resp) < 22: raise e.DataValidationError( diff --git a/broadlink/cover.py b/broadlink/cover.py index 75317943..0319457a 100644 --- a/broadlink/cover.py +++ b/broadlink/cover.py @@ -1,5 +1,5 @@ """Support for covers.""" -import time +import asyncio from typing import Sequence from . import exceptions as e @@ -11,7 +11,7 @@ class dooya(Device): TYPE = "DT360E" - def _send(self, command: int, attribute: int = 0) -> int: + async def _send(self, command: int, attribute: int = 0) -> int: """Send a packet to the device.""" packet = bytearray(16) packet[0x00] = 0x09 @@ -21,42 +21,42 @@ def _send(self, command: int, attribute: int = 0) -> int: packet[0x09] = 0xFA packet[0x0A] = 0x44 - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) return payload[4] - def open(self) -> int: + async def open(self) -> int: """Open the curtain.""" - return self._send(0x01) + return await self._send(0x01) - def close(self) -> int: + async def close(self) -> int: """Close the curtain.""" - return self._send(0x02) + return await self._send(0x02) - def stop(self) -> int: + async def stop(self) -> int: """Stop the curtain.""" - return self._send(0x03) + return await self._send(0x03) - def get_percentage(self) -> int: + async def get_percentage(self) -> int: """Return the position of the curtain.""" - return self._send(0x06, 0x5D) + return await self._send(0x06, 0x5D) - def set_percentage_and_wait(self, new_percentage: int) -> None: + async def set_percentage_and_wait(self, new_percentage: int) -> None: """Set the position of the curtain.""" - current = self.get_percentage() + current = await self.get_percentage() if current > new_percentage: - self.close() + await self.close() while current is not None and current > new_percentage: - time.sleep(0.2) - current = self.get_percentage() + await asyncio.sleep(0.2) + current = await self.get_percentage() elif current < new_percentage: - self.open() + await self.open() while current is not None and current < new_percentage: - time.sleep(0.2) - current = self.get_percentage() - self.stop() + await asyncio.sleep(0.2) + current = await self.get_percentage() + await self.stop() class dooya2(Device): @@ -64,7 +64,7 @@ class dooya2(Device): TYPE = "DT360E-2" - def _send(self, operation: int, data: Sequence = b""): + async def _send(self, operation: int, data: Sequence = b""): """Send a command to the device.""" packet = bytearray(12) packet[0x02] = 0xA5 @@ -89,31 +89,31 @@ def _send(self, operation: int, data: Sequence = b""): packet[0x00] = packet_len & 0xFF packet[0x01] = packet_len >> 8 - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) return payload - def open(self) -> None: + async def open(self) -> None: """Open the curtain.""" - self._send(2, [0x00, 0x01, 0x00]) + await self._send(2, [0x00, 0x01, 0x00]) - def close(self) -> None: + async def close(self) -> None: """Close the curtain.""" - self._send(2, [0x00, 0x02, 0x00]) + await self._send(2, [0x00, 0x02, 0x00]) - def stop(self) -> None: + async def stop(self) -> None: """Stop the curtain.""" - self._send(2, [0x00, 0x03, 0x00]) + await self._send(2, [0x00, 0x03, 0x00]) - def get_percentage(self) -> int: + async def get_percentage(self) -> int: """Return the position of the curtain.""" - resp = self._send(1, [0x00, 0x06, 0x00]) + resp = await self._send(1, [0x00, 0x06, 0x00]) return resp[0x11] - def set_percentage(self, new_percentage: int) -> None: + async def set_percentage(self, new_percentage: int) -> None: """Set the position of the curtain.""" - self._send(2, [0x00, 0x09, new_percentage]) + await self._send(2, [0x00, 0x09, new_percentage]) class wser(Device): @@ -121,7 +121,7 @@ class wser(Device): TYPE = "WSER" - def _send(self, operation: int, data: Sequence = b""): + async def _send(self, operation: int, data: Sequence = b""): """Send a command to the device.""" packet = bytearray(12) packet[0x02] = 0xA5 @@ -146,37 +146,37 @@ def _send(self, operation: int, data: Sequence = b""): packet[0x00] = packet_len & 0xFF packet[0x01] = packet_len >> 8 - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) return payload - def get_position(self) -> int: + async def get_position(self) -> int: """Return the position of the curtain.""" - resp = self._send(1, []) + resp = await self._send(1, []) position = resp[0x0E] return position - def open(self) -> int: + async def open(self) -> int: """Open the curtain.""" - resp = self._send(2, [0x4A, 0x31, 0xA0]) + resp = await self._send(2, [0x4A, 0x31, 0xA0]) position = resp[0x0E] return position - def close(self) -> int: + async def close(self) -> int: """Close the curtain.""" - resp = self._send(2, [0x61, 0x32, 0xA0]) + resp = await self._send(2, [0x61, 0x32, 0xA0]) position = resp[0x0E] return position - def stop(self) -> int: + async def stop(self) -> int: """Stop the curtain.""" - resp = self._send(2, [0x4C, 0x73, 0xA0]) + resp = await self._send(2, [0x4C, 0x73, 0xA0]) position = resp[0x0E] return position - def set_position(self, position: int) -> int: + async def set_position(self, position: int) -> int: """Set the position of the curtain.""" - resp = self._send(2, [position, 0x70, 0xA0]) + resp = await self._send(2, [position, 0x70, 0xA0]) position = resp[0x0E] return position diff --git a/broadlink/device.py b/broadlink/device.py index 5a10bc01..2dc95e0d 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -1,9 +1,19 @@ -"""Support for Broadlink devices.""" -import socket -import threading +"""Support for Broadlink devices. + +Transport layer. Every device method ends up in :meth:`Device.send_packet`, +which frames, encrypts and sends one request over UDP and waits for the one +reply. The protocol is strictly request and reply and the device never +speaks unprompted, so each device keeps a single datagram endpoint and an +``asyncio.Lock`` that serializes calls on it. +""" + +from __future__ import annotations + +import asyncio import random -import time -from typing import Generator, Optional, Tuple, Union +import socket +from collections.abc import AsyncIterator +from typing import Optional, Tuple, Union from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -17,77 +27,141 @@ ) from .protocol import Datetime -HelloResponse = Tuple[int, Tuple[str, int], str, str, bool] +HelloResponse = Tuple[int, Tuple[str, int], bytes, str, bool] +# Device error codes that mean the session key is no longer accepted and a +# fresh auth() will fix it. -7: control key expired; -4012: control id error. +_REAUTH_CODES = {-7, -4012} -def scan( - timeout: int = DEFAULT_TIMEOUT, - local_ip_address: Optional[str] = None, - discover_ip_address: str = DEFAULT_BCAST_ADDR, - discover_ip_port: int = DEFAULT_PORT, -) -> Generator[HelloResponse, None, None]: - """Broadcast a hello message and yield responses.""" - conn = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - conn.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - conn.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - - if local_ip_address: - conn.bind((local_ip_address, 0)) - port = conn.getsockname()[1] - else: - local_ip_address = "0.0.0.0" - port = 0 +class _Protocol(asyncio.DatagramProtocol): + """Datagram protocol that hands every received packet to a queue.""" + + def __init__(self) -> None: + self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue() + self.transport: Optional[asyncio.DatagramTransport] = None + self.closed = asyncio.get_running_loop().create_future() + + def connection_made(self, transport) -> None: # type: ignore[override] + self.transport = transport + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + self.queue.put_nowait((data, addr)) + + def error_received(self, exc: Exception) -> None: + # ICMP unreachable and the like. Surface it as a receive of nothing; + # the retry loop will time out and raise NetworkTimeoutError. + pass + + def connection_lost(self, exc: Optional[Exception]) -> None: + if not self.closed.done(): + self.closed.set_result(None) + + def drain(self) -> None: + """Drop anything that arrived before the current request.""" + while not self.queue.empty(): + self.queue.get_nowait() + + +async def _open_endpoint( + local_addr: Optional[tuple[str, int]] = None, + remote_addr: Optional[tuple[str, int]] = None, + broadcast: bool = False, +) -> tuple[asyncio.DatagramTransport, _Protocol]: + """Create a UDP endpoint. Tests replace this to fake the network.""" + loop = asyncio.get_running_loop() + transport, protocol = await loop.create_datagram_endpoint( + _Protocol, + local_addr=local_addr, + remote_addr=remote_addr, + family=socket.AF_INET, + allow_broadcast=broadcast, + ) + return transport, protocol # type: ignore[return-value] + + +def _hello_packet(local_ip_address: str, port: int) -> bytearray: packet = bytearray(0x30) packet[0x08:0x14] = Datetime.pack(Datetime.now()) packet[0x18:0x1C] = socket.inet_aton(local_ip_address)[::-1] packet[0x1C:0x1E] = port.to_bytes(2, "little") packet[0x26] = 6 - checksum = sum(packet, 0xBEAF) & 0xFFFF packet[0x20:0x22] = checksum.to_bytes(2, "little") + return packet - start_time = time.time() - discovered = [] - try: - while (time.time() - start_time) < timeout: - time_left = timeout - (time.time() - start_time) - conn.settimeout(min(DEFAULT_RETRY_INTVL, time_left)) - conn.sendto(packet, (discover_ip_address, discover_ip_port)) +def _parse_hello(resp: bytes, host: tuple[str, int]) -> HelloResponse: + devtype = resp[0x34] | resp[0x35] << 8 + mac = resp[0x3A:0x40][::-1] + name = resp[0x40:].split(b"\x00")[0].decode() + is_locked = bool(resp[0x7F]) + return devtype, host, mac, name, is_locked + +async def scan( + timeout: float = DEFAULT_TIMEOUT, + local_ip_address: Optional[str] = None, + discover_ip_address: str = DEFAULT_BCAST_ADDR, + discover_ip_port: int = DEFAULT_PORT, +) -> AsyncIterator[HelloResponse]: + """Broadcast a hello message and yield responses as they arrive. + + The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until + ``timeout`` elapses. Each device is yielded once. + """ + local_addr = (local_ip_address, 0) if local_ip_address else None + transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True) + try: + if local_ip_address: + port = transport.get_extra_info("sockname")[1] + else: + local_ip_address = "0.0.0.0" + port = 0 + packet = _hello_packet(local_ip_address, port) + + loop = asyncio.get_running_loop() + start = loop.time() + discovered: set[tuple[tuple[str, int], bytes, int]] = set() + + while (loop.time() - start) < timeout: + transport.sendto(packet, (discover_ip_address, discover_ip_port)) + deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start)) + slot_end = loop.time() + deadline while True: + remaining = slot_end - loop.time() + if remaining <= 0: + break try: - resp, host = conn.recvfrom(1024) - except socket.timeout: + resp, host = await asyncio.wait_for(protocol.queue.get(), remaining) + except asyncio.TimeoutError: break - - devtype = resp[0x34] | resp[0x35] << 8 - mac = resp[0x3A:0x40][::-1] - - if (host, mac, devtype) in discovered: + if len(resp) < 0x80: continue - discovered.append((host, mac, devtype)) - - name = resp[0x40:].split(b"\x00")[0].decode() - is_locked = bool(resp[0x7F]) - yield devtype, host, mac, name, is_locked + entry = _parse_hello(resp, host) + key = (entry[1], entry[2], entry[0]) + if key in discovered: + continue + discovered.add(key) + yield entry finally: - conn.close() + transport.close() -def ping(ip_address: str, port: int = DEFAULT_PORT) -> None: +async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None: """Send a ping packet to an address. This packet feeds the watchdog timer of firmwares >= v53. Useful to prevent reboots when the cloud cannot be reached. It must be sent every 2 minutes in such cases. """ - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as conn: - conn.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + transport, _ = await _open_endpoint(broadcast=True) + try: packet = bytearray(0x30) packet[0x26] = 1 - conn.sendto(packet, (ip_address, port)) + transport.sendto(packet, (ip_address, port)) + finally: + transport.close() class Device: @@ -103,7 +177,7 @@ def __init__( host: Tuple[str, int], mac: Union[bytes, str], devtype: int, - timeout: int = DEFAULT_TIMEOUT, + timeout: float = DEFAULT_TIMEOUT, name: str = "", model: str = "", manufacturer: str = "", @@ -122,11 +196,15 @@ def __init__( self.iv = bytes.fromhex(self.__INIT_VECT) self.id = 0 self.type = self.TYPE # For backwards compatibility. - self.lock = threading.Lock() self.aes = None self.update_aes(bytes.fromhex(self.__INIT_KEY)) + self._lock: Optional[asyncio.Lock] = None + self._transport: Optional[asyncio.DatagramTransport] = None + self._protocol: Optional[_Protocol] = None + self._reauth_ok = True + def __repr__(self) -> str: """Return a formal representation of the device.""" return ( @@ -154,6 +232,14 @@ def __str__(self) -> str: ":".join(format(x, "02X") for x in self.mac), ) + async def __aenter__(self) -> "Device": + return self + + async def __aexit__(self, *exc) -> None: + await self.aclose() + + # ------------------------------------------------------------ crypto + def update_aes(self, key: bytes) -> None: """Update AES.""" self.aes = Cipher( @@ -170,7 +256,9 @@ def decrypt(self, payload: bytes) -> bytes: decryptor = self.aes.decryptor() return decryptor.update(bytes(payload)) + decryptor.finalize() - def auth(self) -> bool: + # ---------------------------------------------------------- session + + async def auth(self) -> bool: """Authenticate to the device.""" self.id = 0 self.update_aes(bytes.fromhex(self.__INIT_KEY)) @@ -181,7 +269,7 @@ def auth(self) -> bool: packet[0x2D] = 0x01 packet[0x30:0x36] = "Test 1".encode() - response = self.send_packet(0x65, packet) + response = await self.send_packet(0x65, packet, _reauth=False) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) @@ -189,7 +277,7 @@ def auth(self) -> bool: self.update_aes(payload[0x04:0x14]) return True - def hello(self, local_ip_address=None) -> bool: + async def hello(self, local_ip_address=None) -> bool: """Send a hello message to the device. Device information is checked before updating name and lock status. @@ -200,15 +288,16 @@ def hello(self, local_ip_address=None) -> bool: discover_ip_address=self.host[0], discover_ip_port=self.host[1], ) - try: - devtype, _, mac, name, is_locked = next(responses) - - except StopIteration as err: + entry = None + async for entry in responses: + break + if entry is None: raise e.NetworkTimeoutError( -4000, "Network timeout", f"No response received within {self.timeout}s", - ) from err + ) + devtype, _, mac, name, is_locked = entry if mac != self.mac: raise e.DataValidationError( @@ -230,40 +319,40 @@ def hello(self, local_ip_address=None) -> bool: self.is_locked = is_locked return True - def ping(self) -> None: + async def ping(self) -> None: """Ping the device. This packet feeds the watchdog timer of firmwares >= v53. Useful to prevent reboots when the cloud cannot be reached. It must be sent every 2 minutes in such cases. """ - ping(self.host[0], port=self.host[1]) + await ping(self.host[0], port=self.host[1]) - def get_fwversion(self) -> int: + async def get_fwversion(self) -> int: """Get firmware version.""" packet = bytearray([0x68]) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return payload[0x4] | payload[0x5] << 8 - def set_name(self, name: str) -> None: + async def set_name(self, name: str) -> None: """Set device name.""" packet = bytearray(4) packet += name.encode("utf-8") packet += bytearray(0x50 - len(packet)) packet[0x43] = self.is_locked - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) self.name = name - def set_lock(self, state: bool) -> None: + async def set_lock(self, state: bool) -> None: """Lock/unlock the device.""" packet = bytearray(4) packet += self.name.encode("utf-8") packet += bytearray(0x50 - len(packet)) packet[0x43] = bool(state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) self.is_locked = bool(state) @@ -271,8 +360,24 @@ def get_type(self) -> str: """Return device type.""" return self.type - def send_packet(self, packet_type: int, payload: bytes) -> bytes: - """Send a packet to the device.""" + # -------------------------------------------------------- transport + + async def aclose(self) -> None: + """Close the device's endpoint. It is reopened on the next call.""" + if self._transport is not None: + self._transport.close() + self._transport = None + self._protocol = None + + async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]: + if self._transport is None or self._transport.is_closing(): + self._transport, self._protocol = await _open_endpoint( + remote_addr=self.host + ) + return self._transport, self._protocol # type: ignore[return-value] + + def _frame(self, packet_type: int, payload: bytes) -> bytes: + """Build the wire frame for one request (advances the counter).""" self.count = ((self.count + 1) | 0x8000) & 0xFFFF packet = bytearray(0x38) packet[0x00:0x08] = bytes.fromhex("5aa5aa555aa5aa55") @@ -291,27 +396,10 @@ def send_packet(self, packet_type: int, payload: bytes) -> bytes: checksum = sum(packet, 0xBEAF) & 0xFFFF packet[0x20:0x22] = checksum.to_bytes(2, "little") + return bytes(packet) - with self.lock and socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as conn: - timeout = self.timeout - start_time = time.time() - - while True: - time_left = timeout - (time.time() - start_time) - conn.settimeout(min(DEFAULT_RETRY_INTVL, time_left)) - conn.sendto(packet, self.host) - - try: - resp = conn.recvfrom(2048)[0] - break - except socket.timeout as err: - if (time.time() - start_time) > timeout: - raise e.NetworkTimeoutError( - -4000, - "Network timeout", - f"No response received within {timeout}s", - ) from err - + @staticmethod + def _validate(resp: bytes) -> bytes: if len(resp) < 0x30: raise e.DataValidationError( -4007, @@ -328,5 +416,55 @@ def send_packet(self, packet_type: int, payload: bytes) -> bytes: "Received data packet check error", f"Expected a checksum of {nom_checksum} and received {real_checksum}", ) + return resp + async def _exchange(self, packet: bytes) -> bytes: + """Send one frame and wait for one reply, resending on silence.""" + transport, protocol = await self._endpoint() + protocol.drain() + loop = asyncio.get_running_loop() + start = loop.time() + timeout = self.timeout + + while True: + transport.sendto(packet) + time_left = timeout - (loop.time() - start) + wait = min(DEFAULT_RETRY_INTVL, time_left) + try: + resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0)) + except asyncio.TimeoutError: + if (loop.time() - start) >= timeout: + raise e.NetworkTimeoutError( + -4000, + "Network timeout", + f"No response received within {timeout}s", + ) from None + continue + return self._validate(resp) + + async def send_packet( + self, packet_type: int, payload: bytes, *, _reauth: bool = True + ) -> bytes: + """Send a packet to the device and return the raw response frame. + + If the device answers that the session key is no longer valid, the + session is re-authenticated once and the request is sent again. + """ + if self._lock is None: + self._lock = asyncio.Lock() + async with self._lock: + resp = await self._exchange(self._frame(packet_type, bytes(payload))) + + if _reauth and self._reauth_ok: + code = int.from_bytes(resp[0x22:0x24], "little", signed=True) + if code in _REAUTH_CODES: + self._reauth_ok = False + try: + await self.auth() + async with self._lock: + resp = await self._exchange( + self._frame(packet_type, bytes(payload)) + ) + finally: + self._reauth_ok = True return resp diff --git a/broadlink/exceptions.py b/broadlink/exceptions.py index 2343ad6e..8f2ecc6c 100644 --- a/broadlink/exceptions.py +++ b/broadlink/exceptions.py @@ -95,6 +95,15 @@ class StorageError(BroadlinkException): """Storage error.""" +class CaptureInProgressError(BroadlinkException): + """A capture window is already open on this device. + + A universal remote has one receiver, so only one ``capture`` or + ``capture_rf`` window can be open at a time. Close the running one + before opening another. + """ + + class WriteError(BroadlinkException): """Write error.""" diff --git a/broadlink/hub.py b/broadlink/hub.py index 0fd4ae53..1d74041f 100644 --- a/broadlink/hub.py +++ b/broadlink/hub.py @@ -1,6 +1,6 @@ """Support for hubs.""" -import struct import json +import struct from typing import Optional from . import exceptions as e @@ -13,7 +13,7 @@ class s3(Device): TYPE = "S3" MAX_SUBDEVICES = 8 - def get_subdevices(self, step: int = 5) -> list: + async def get_subdevices(self, step: int = 5) -> list: """Return a list of sub devices.""" total = self.MAX_SUBDEVICES sub_devices = [] @@ -23,7 +23,7 @@ def get_subdevices(self, step: int = 5) -> list: while index < total: state = {"count": step, "index": index} packet = self._encode(14, state) - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) resp = self._decode(resp) @@ -43,18 +43,18 @@ def get_subdevices(self, step: int = 5) -> list: return sub_devices - def get_state(self, did: Optional[str] = None) -> dict: + async def get_state(self, did: Optional[str] = None) -> dict: """Return the power state of the device.""" state = {} if did is not None: state["did"] = did packet = self._encode(1, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) - def set_state( + async def set_state( self, did: Optional[str] = None, pwr1: Optional[bool] = None, @@ -73,7 +73,7 @@ def set_state( state["pwr3"] = int(bool(pwr3)) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) diff --git a/broadlink/light.py b/broadlink/light.py index 1ae87e8f..6225887e 100644 --- a/broadlink/light.py +++ b/broadlink/light.py @@ -21,17 +21,17 @@ class ColorMode(enum.IntEnum): WHITE = 1 SCENE = 2 - def get_state(self) -> dict: + async def get_state(self) -> dict: """Return the power state of the device. Example: `{'red': 128, 'blue': 255, 'green': 128, 'pwr': 1, 'brightness': 75, 'colortemp': 2700, 'hue': 240, 'saturation': 50, 'transitionduration': 1500, 'maxworktime': 0, 'bulb_colormode': 1, 'bulb_scenes': '["@01686464,0,0,0", "#ffffff,10,0,#000000,190,0,0", "2700+100,0,0,0", "#ff0000,500,2500,#00FF00,500,2500,#0000FF,500,2500,0", "@01686464,100,2400,@01686401,100,2400,0", "@01686464,100,2400,@01686401,100,2400,@005a6464,100,2400,@005a6401,100,2400,0", "@01686464,10,0,@00000000,190,0,0", "@01686464,200,0,@005a6464,200,0,0"]', 'bulb_scene': '', 'bulb_sceneidx': 255}` """ packet = self._encode(1, {}) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) - def set_state( + async def set_state( self, pwr: Optional[bool] = None, red: Optional[int] = None, @@ -80,7 +80,7 @@ def set_state( state["bulb_sceneidx"] = int(bulb_sceneidx) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) @@ -119,17 +119,17 @@ class ColorMode(enum.IntEnum): WHITE = 1 SCENE = 2 - def get_state(self) -> dict: + async def get_state(self) -> dict: """Return the power state of the device. Example: `{'red': 128, 'blue': 255, 'green': 128, 'pwr': 1, 'brightness': 75, 'colortemp': 2700, 'hue': 240, 'saturation': 50, 'transitionduration': 1500, 'maxworktime': 0, 'bulb_colormode': 1, 'bulb_scenes': '["@01686464,0,0,0", "#ffffff,10,0,#000000,190,0,0", "2700+100,0,0,0", "#ff0000,500,2500,#00FF00,500,2500,#0000FF,500,2500,0", "@01686464,100,2400,@01686401,100,2400,0", "@01686464,100,2400,@01686401,100,2400,@005a6464,100,2400,@005a6401,100,2400,0", "@01686464,10,0,@00000000,190,0,0", "@01686464,200,0,@005a6464,200,0,0"]', 'bulb_scene': ''}` """ packet = self._encode(1, {}) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) - def set_state( + async def set_state( self, pwr: Optional[bool] = None, red: Optional[int] = None, @@ -175,7 +175,7 @@ def set_state( state["bulb_scene"] = str(bulb_scene) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) diff --git a/broadlink/remote.py b/broadlink/remote.py index 60c54ce2..9f905618 100644 --- a/broadlink/remote.py +++ b/broadlink/remote.py @@ -1,18 +1,95 @@ """Support for universal remotes.""" + +import asyncio +import enum import struct -from typing import List, Optional, Tuple +import time +from dataclasses import dataclass, field +from typing import AsyncIterator, Awaitable, Callable, List, Optional, Tuple from . import exceptions as e from .device import Device - -def pulses_to_data(pulses: List[int], tick: float = 32.84) -> bytes: - """Convert a microsecond duration sequence into a Broadlink IR packet.""" +TICK = 8192 / 269 +"""Duration of one Broadlink timing unit in microseconds (about 30.45 us). + +The RM firmware counts pulses on a 32768 Hz clock (protocol.md: us * 269 / 8192). +Earlier releases used 32.84, the inverse of the right ratio applied the wrong +way round, which compressed externally sourced IR codes by about 7 percent +(mjg59/python-broadlink#839). Codes learned and replayed through the same +device were unaffected because both directions shared the constant. +""" + +DEFAULT_POLL_INTERVAL = 0.5 +"""Seconds between ``check_data`` polls while a capture window is open.""" + +DEFAULT_REARM_INTERVAL = 15.0 +"""Seconds after which an open capture window re-enters learning mode. + +The RM4 Pro leaves learning mode silently between 25 s and 40 s after +``enter_learning`` (bench, 2026-09-04), and any ``send_data`` also ends the +session, while ``check_data`` keeps answering with the same "nothing yet" +error, so an open window has to re-arm on a timer and after every send. +""" + + +class SignalKind(enum.IntEnum): + """The kind of signal a packet carries. + + The values are the canonical type bytes the library writes when it + builds a packet (protocol.md offset 0x00). Packets a device returns + from a learn session do not always use exactly these bytes -- an RM4 + Pro returns 0xB1 for a 433 MHz capture, not 0xB2 -- so read a returned + packet's kind with ``classify`` rather than by equality. + """ + + IR = 0x26 + RF_433 = 0xB2 + RF_315 = 0xD7 + + @property + def is_rf(self) -> bool: + return self is not SignalKind.IR + + @classmethod + def classify(cls, type_byte: int) -> "SignalKind": + """Map a packet's raw first byte to a kind, tolerantly. + + The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_ + (315 MHz) ranges whose low bits are not documented and vary by + firmware, so classify by range rather than by exact value. Raises + ``ValueError`` for a byte in no known range. + """ + if type_byte == cls.IR: + return cls.IR + if type_byte & 0xF0 == 0xB0: + return cls.RF_433 + if type_byte & 0xF0 == 0xD0: + return cls.RF_315 + raise ValueError(f"Unknown packet type 0x{type_byte:02x}") + + +def pulses_to_data( + pulses: List[int], + tick: float = TICK, + *, + kind: SignalKind = SignalKind.IR, + repeat: int = 0, +) -> bytes: + """Convert a microsecond duration sequence into a Broadlink packet. + + ``kind`` selects the type byte (IR, RF 433 MHz or RF 315 MHz) and + ``repeat`` is the number of extra transmissions the device performs + after the first, 0 to 255 (protocol.md offset 0x01). + """ + if not 0 <= repeat <= 0xFF: + raise ValueError("repeat must be between 0 and 255") result = bytearray(4) - result[0x00] = 0x26 + result[0x00] = SignalKind(kind) + result[0x01] = repeat for pulse in pulses: - div, mod = divmod(int(pulse // tick), 256) + div, mod = divmod(round(pulse / tick), 256) if div: result.append(0) result.append(div) @@ -22,10 +99,10 @@ def pulses_to_data(pulses: List[int], tick: float = 32.84) -> bytes: result[0x02] = data_len & 0xFF result[0x03] = data_len >> 8 - return result + return bytes(result) -def data_to_pulses(data: bytes, tick: float = 32.84) -> List[int]: +def data_to_pulses(data: bytes, tick: float = TICK) -> List[int]: """Parse a Broadlink packet into a microsecond duration sequence.""" result = [] index = 4 @@ -47,36 +124,222 @@ def data_to_pulses(data: bytes, tick: float = 32.84) -> List[int]: return result +@dataclass(frozen=True) +class ParsedPacket: + """The parts of a Broadlink packet: kind, repeat count and timings. + + ``type_byte`` is the packet's raw first byte; ``kind`` is that byte + classified into a band (see ``SignalKind.classify``), which for a + device-returned RF packet is not always the canonical value. + """ + + kind: SignalKind + repeat: int + pulses: List[int] + type_byte: int + + +def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket: + """Split a Broadlink packet into its kind, repeat count and timings. + + Raises ``ValueError`` if the packet is shorter than its header or the + type byte is in no known band (IR, 433 MHz or 315 MHz). + """ + if len(data) < 4: + raise ValueError("Malformed data.") + kind = SignalKind.classify(data[0x00]) + return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00]) + + +@dataclass(frozen=True) +class CapturedSignal: + """One signal captured by a universal remote. + + ``packet`` is the device's own bytes, ready for ``send_data`` and for + storage; ``pulses`` is the same signal as microsecond durations at the + corrected tick. ``kind`` is the band the signal was captured on; + ``type_byte`` is the packet's raw first byte, which for RF is not always + the canonical value for the band. ``frequency_mhz`` is set for RF + captures only and holds the carrier the device swept to or was given, + which the packet itself does not record. + """ + + packet: bytes + kind: SignalKind + pulses: List[int] = field(repr=False) + repeat: int = 0 + frequency_mhz: Optional[float] = None + type_byte: Optional[int] = None + captured_at: float = field(default_factory=time.time, repr=False) + + @classmethod + def from_packet( + cls, + packet: bytes, + frequency_mhz: Optional[float] = None, + *, + kind: Optional[SignalKind] = None, + ) -> "CapturedSignal": + """Build a signal from a device-returned packet. + + ``kind`` overrides the band read from the packet's type byte. A + capture window knows what it armed, so it passes the kind it armed + for and a signal is never dropped over an unexpected type byte; the + raw byte is still kept in ``type_byte``. The timings are read from + the packet regardless of the type byte. + """ + if len(packet) < 4: + raise ValueError("Malformed data.") + type_byte = packet[0x00] + if kind is None: + kind = SignalKind.classify(type_byte) + return cls( + bytes(packet), + kind, + data_to_pulses(packet), + packet[0x01], + frequency_mhz, + type_byte, + ) + + class rmmini(Device): """Controls a Broadlink RM mini 3.""" TYPE = "RMMINI" - def _send(self, command: int, data: bytes = b"") -> bytes: + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + # Bumped by every transmission. An open capture window compares it + # against the value it saw when it armed the device and re-arms + # after any send, since the device has one front end for both. + self._tx_generation = 0 + self._capture_open = False + + async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a packet to the device.""" packet = struct.pack(" None: + async def update(self) -> None: """Update device name and lock status.""" - resp = self._send(0x1) + resp = await self._send(0x1) self.name = resp[0x48:].split(b"\x00")[0].decode() self.is_locked = bool(resp[0x87]) - def send_data(self, data: bytes) -> None: + async def send_data(self, data: bytes) -> None: """Send a code to the device.""" - self._send(0x2, data) + self._tx_generation += 1 + await self._send(0x2, data) - def enter_learning(self) -> None: + async def enter_learning(self) -> None: """Enter infrared learning mode.""" - self._send(0x3) + await self._send(0x3) - def check_data(self) -> bytes: + async def check_data(self) -> bytes: """Return the last captured code.""" - return self._send(0x4) + return await self._send(0x4) + + def capture( + self, + window: float = 30.0, + *, + stop_after_first: bool = True, + poll_interval: float = DEFAULT_POLL_INTERVAL, + rearm_interval: float = DEFAULT_REARM_INTERVAL, + ) -> AsyncIterator[CapturedSignal]: + """Open an infrared capture window and yield what the device hears. + + The device is put into learning mode and polled every + ``poll_interval`` seconds. Each code it reports is yielded as a + ``CapturedSignal``. With ``stop_after_first`` the window closes + after the first code; otherwise the device is re-armed after each + code (it holds one code per learning session) and the window stays + open until ``window`` seconds have passed. ``window=0`` keeps it + open until the generator is closed. + + The device leaves learning mode on its own after a while without + saying so, so the window re-arms it every ``rearm_interval`` seconds + and after every ``send_data`` on the same device. Closing the + generator sends nothing further; the device times out by itself. + Use ``contextlib.aclosing`` (or iterate to the end) so the window is + released promptly. Only one capture window can be open per device; + a second raises ``CaptureInProgressError``. + """ + return self._capture_loop( + self.enter_learning, + window, + stop_after_first, + poll_interval, + rearm_interval, + SignalKind.IR, + None, + ) + + async def _capture_loop( + self, + arm: Callable[[], Awaitable[None]], + window: float, + stop_after_first: bool, + poll_interval: float, + rearm_interval: float, + kind: SignalKind, + frequency_mhz: Optional[float], + ) -> AsyncIterator[CapturedSignal]: + if window < 0: + raise ValueError("window must be 0 (open-ended) or positive") + if poll_interval <= 0 or rearm_interval <= 0: + raise ValueError("poll_interval and rearm_interval must be positive") + if self._capture_open: + raise e.CaptureInProgressError("A capture window is already open") + + self._capture_open = True + try: + loop = asyncio.get_running_loop() + deadline = loop.time() + window if window else None + timeouts = 0 + + await arm() + armed_at = loop.time() + generation = self._tx_generation + + while True: + now = loop.time() + if deadline is not None and now >= deadline: + return + delay = poll_interval + if deadline is not None: + delay = min(delay, deadline - now) + await asyncio.sleep(delay) + + try: + data = await self.check_data() + except e.StorageError: + data = b"" # The device's answer for "nothing yet". + except e.NetworkTimeoutError: + timeouts += 1 + if timeouts >= 3: + raise + generation = -1 # Re-arm; the device's state is unknown. + continue + timeouts = 0 + + if data: + yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind) + if stop_after_first: + return + generation = -1 # One code per session: re-arm. + + now = loop.time() + if generation != self._tx_generation or now - armed_at >= rearm_interval: + await arm() + armed_at = loop.time() + generation = self._tx_generation + finally: + self._capture_open = False class rmpro(rmmini): @@ -84,37 +347,116 @@ class rmpro(rmmini): TYPE = "RMPRO" - def sweep_frequency(self) -> None: + async def sweep_frequency(self) -> None: """Sweep frequency.""" - self._send(0x19) + await self._send(0x19) - def check_frequency(self) -> Tuple[bool, float]: + async def check_frequency(self) -> Tuple[bool, float]: """Return True if the frequency was identified successfully.""" - resp = self._send(0x1A) + resp = await self._send(0x1A) is_found = bool(resp[0]) frequency = struct.unpack(" None: + async def find_rf_packet(self, frequency: Optional[float] = None) -> None: """Enter radiofrequency learning mode.""" payload = bytearray() if frequency: payload += struct.pack(" None: + async def cancel_sweep_frequency(self) -> None: """Cancel sweep frequency.""" - self._send(0x1E) - - def check_sensors(self) -> dict: + await self._send(0x1E) + + async def capture_rf( + self, + window: float = 30.0, + *, + frequency: Optional[float] = None, + stop_after_first: bool = True, + poll_interval: float = DEFAULT_POLL_INTERVAL, + rearm_interval: float = DEFAULT_REARM_INTERVAL, + ) -> AsyncIterator[CapturedSignal]: + """Open a radio frequency capture window and yield what the device hears. + + With ``frequency`` (in MHz, for example 433.92) the device goes + straight into RF learning mode on that carrier. Without it the + device first sweeps for the carrier while the user HOLDS a button on + the remote, and only then learns the code from a fresh press; the + sweep is unreliable on some firmware and can report a carrier it + never really locked, so pass the frequency whenever it is known. + + The window, polling, re-arm and stop-after-first semantics are those + of ``capture``; the sweep counts against the same ``window``. A + ``send_data`` during the sweep restarts it. Each ``CapturedSignal`` + carries the carrier in ``frequency_mhz``, which the packet itself + does not record. + """ + if self._capture_open: + raise e.CaptureInProgressError("A capture window is already open") + if window < 0 or poll_interval <= 0: + raise ValueError("window must be 0 or positive, poll_interval positive") + + loop = asyncio.get_running_loop() + deadline = loop.time() + window if window else None + + if frequency is None: + self._capture_open = True + try: + frequency = await self._sweep(deadline, poll_interval) + finally: + self._capture_open = False + if frequency is None: + return + if deadline is not None: + window = max(deadline - loop.time(), 0.0) + if window == 0: + return + + async def arm() -> None: + await self.find_rf_packet(frequency) + + kind = SignalKind.RF_315 if frequency < 400 else SignalKind.RF_433 + async for signal in self._capture_loop( + arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency + ): + yield signal + + async def _sweep( + self, deadline: Optional[float], poll_interval: float + ) -> Optional[float]: + """Sweep for the remote's carrier; return it in MHz, or None if the + window ran out first.""" + loop = asyncio.get_running_loop() + await self.sweep_frequency() + generation = self._tx_generation + while True: + now = loop.time() + if deadline is not None and now >= deadline: + await self.cancel_sweep_frequency() + return None + delay = poll_interval + if deadline is not None: + delay = min(delay, deadline - now) + await asyncio.sleep(delay) + if generation != self._tx_generation: + await self.sweep_frequency() + generation = self._tx_generation + continue + found, frequency = await self.check_frequency() + if found: + return frequency + + async def check_sensors(self) -> dict: """Return the state of the sensors.""" - resp = self._send(0x1) + resp = await self._send(0x1) temp = struct.unpack(" float: + async def check_temperature(self) -> float: """Return the temperature.""" - return self.check_sensors()["temperature"] + return (await self.check_sensors())["temperature"] class rmminib(rmmini): @@ -122,10 +464,10 @@ class rmminib(rmmini): TYPE = "RMMINIB" - def _send(self, command: int, data: bytes = b"") -> bytes: + async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a packet to the device.""" packet = struct.pack(" dict: + async def check_sensors(self) -> dict: """Return the state of the sensors.""" - resp = self._send(0x24) + resp = await self._send(0x24) temp = struct.unpack(" float: + async def check_temperature(self) -> float: """Return the temperature.""" - return self.check_sensors()["temperature"] + return (await self.check_sensors())["temperature"] - def check_humidity(self) -> float: + async def check_humidity(self) -> float: """Return the humidity.""" - return self.check_sensors()["humidity"] + return (await self.check_sensors())["humidity"] class rm4pro(rm4mini, rmpro): @@ -171,3 +513,9 @@ class rm4(rm4pro): """For backwards compatibility.""" TYPE = "RM4" + + +class rm5plus(rmminib): + """Controls a Broadlink RM5 Plus.""" + + TYPE = "RM5PLUS" diff --git a/broadlink/sensor.py b/broadlink/sensor.py index 284576fa..f0a99029 100644 --- a/broadlink/sensor.py +++ b/broadlink/sensor.py @@ -16,9 +16,9 @@ class a1(Device): ("noise", ("quiet", "normal", "noisy")), ) - def check_sensors(self) -> dict: + async def check_sensors(self) -> dict: """Return the state of the sensors.""" - data = self.check_sensors_raw() + data = await self.check_sensors_raw() for sensor, levels in self._SENSORS_AND_LEVELS: try: data[sensor] = levels[data[sensor]] @@ -26,10 +26,10 @@ def check_sensors(self) -> dict: data[sensor] = "unknown" return data - def check_sensors_raw(self) -> dict: + async def check_sensors_raw(self) -> dict: """Return the state of the sensors in raw format.""" packet = bytearray([0x1]) - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) data = self.decrypt(resp[0x38:]) @@ -47,7 +47,7 @@ class a2(Device): TYPE = "A2" - def _send(self, operation: int, data: Sequence = b""): + async def _send(self, operation: int, data: Sequence = b""): """Send a command to the device.""" packet = bytearray(12) packet[0x02] = 0xA5 @@ -72,14 +72,14 @@ def _send(self, operation: int, data: Sequence = b""): packet[0x00] = packet_len & 0xFF packet[0x01] = packet_len >> 8 - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) return payload - def check_sensors_raw(self) -> dict: + async def check_sensors_raw(self) -> dict: """Return the state of the sensors in raw format.""" - data = self._send(1) + data = await self._send(1) return { "temperature": data[0x13] * 256 + data[0x14], diff --git a/broadlink/switch.py b/broadlink/switch.py index 8393f6b1..b41e220d 100644 --- a/broadlink/switch.py +++ b/broadlink/switch.py @@ -12,11 +12,11 @@ class sp1(Device): TYPE = "SP1" - def set_power(self, pwr: bool) -> None: + async def set_power(self, pwr: bool) -> None: """Set the power state of the device.""" packet = bytearray(4) packet[0] = bool(pwr) - response = self.send_packet(0x66, packet) + response = await self.send_packet(0x66, packet) e.check_error(response[0x22:0x24]) @@ -25,19 +25,19 @@ class sp2(Device): TYPE = "SP2" - def set_power(self, pwr: bool) -> None: + async def set_power(self, pwr: bool) -> None: """Set the power state of the device.""" packet = bytearray(16) packet[0] = 2 packet[4] = bool(pwr) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - def check_power(self) -> bool: + async def check_power(self) -> bool: """Return the power state of the device.""" packet = bytearray(16) packet[0] = 1 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return bool(payload[0x4]) @@ -48,11 +48,11 @@ class sp2s(sp2): TYPE = "SP2S" - def get_energy(self) -> float: + async def get_energy(self) -> float: """Return the power consumption in W.""" packet = bytearray(16) packet[0] = 4 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return int.from_bytes(payload[0x4:0x7], "little") / 1000 @@ -63,36 +63,36 @@ class sp3(Device): TYPE = "SP3" - def set_power(self, pwr: bool) -> None: + async def set_power(self, pwr: bool) -> None: """Set the power state of the device.""" packet = bytearray(16) packet[0] = 2 - packet[4] = self.check_nightlight() << 1 | bool(pwr) - response = self.send_packet(0x6A, packet) + packet[4] = await self.check_nightlight() << 1 | bool(pwr) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - def set_nightlight(self, ntlight: bool) -> None: + async def set_nightlight(self, ntlight: bool) -> None: """Set the night light state of the device.""" packet = bytearray(16) packet[0] = 2 - packet[4] = bool(ntlight) << 1 | self.check_power() - response = self.send_packet(0x6A, packet) + packet[4] = bool(ntlight) << 1 | await self.check_power() + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - def check_power(self) -> bool: + async def check_power(self) -> bool: """Return the power state of the device.""" packet = bytearray(16) packet[0] = 1 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return bool(payload[0x4] & 1) - def check_nightlight(self) -> bool: + async def check_nightlight(self) -> bool: """Return the state of the night light.""" packet = bytearray(16) packet[0] = 1 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return bool(payload[0x4] & 2) @@ -103,10 +103,10 @@ class sp3s(sp2): TYPE = "SP3S" - def get_energy(self) -> float: + async def get_energy(self) -> float: """Return the power consumption in W.""" packet = bytearray([8, 0, 254, 1, 5, 1, 0, 0, 0, 45]) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) energy = payload[0x7:0x4:-1].hex() @@ -118,15 +118,15 @@ class sp4(Device): TYPE = "SP4" - def set_power(self, pwr: bool) -> None: + async def set_power(self, pwr: bool) -> None: """Set the power state of the device.""" - self.set_state(pwr=pwr) + await self.set_state(pwr=pwr) - def set_nightlight(self, ntlight: bool) -> None: + async def set_nightlight(self, ntlight: bool) -> None: """Set the night light state of the device.""" - self.set_state(ntlight=ntlight) + await self.set_state(ntlight=ntlight) - def set_state( + async def set_state( self, pwr: Optional[bool] = None, ntlight: Optional[bool] = None, @@ -151,23 +151,23 @@ def set_state( state["childlock"] = int(bool(childlock)) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) return self._decode(response) - def check_power(self) -> bool: + async def check_power(self) -> bool: """Return the power state of the device.""" - state = self.get_state() + state = await self.get_state() return bool(state["pwr"]) - def check_nightlight(self) -> bool: + async def check_nightlight(self) -> bool: """Return the state of the night light.""" - state = self.get_state() + state = await self.get_state() return bool(state["ntlight"]) - def get_state(self) -> dict: + async def get_state(self) -> dict: """Get full state of device.""" packet = self._encode(1, {}) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) return self._decode(response) def _encode(self, flag: int, state: dict) -> bytes: @@ -196,9 +196,9 @@ class sp4b(sp4): TYPE = "SP4B" - def get_state(self) -> dict: + async def get_state(self) -> dict: """Get full state of device.""" - state = super().get_state() + state = await super().get_state() # Convert sensor data to float. Remove keys if sensors are not supported. sensor_attrs = ["current", "volt", "power", "totalconsum", "overload"] @@ -244,17 +244,17 @@ class bg1(Device): TYPE = "BG1" - def get_state(self) -> dict: + async def get_state(self) -> dict: """Return the power state of the device. Example: `{"pwr":1,"pwr1":1,"pwr2":0,"maxworktime":60,"maxworktime1":60,"maxworktime2":0,"idcbrightness":50}` """ packet = self._encode(1, {}) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) - def set_state( + async def set_state( self, pwr: Optional[bool] = None, pwr1: Optional[bool] = None, @@ -282,7 +282,7 @@ def set_state( state["idcbrightness"] = idcbrightness packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) @@ -321,7 +321,7 @@ class ehc31(bg1): TYPE = "EHC31" - def set_state( + async def set_state( self, pwr: Optional[bool] = None, pwr1: Optional[bool] = None, @@ -367,7 +367,7 @@ def set_state( state["childlock4"] = int(bool(childlock4)) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) @@ -377,7 +377,7 @@ class mp1(Device): TYPE = "MP1" - def set_power_mask(self, sid_mask: int, pwr: bool) -> None: + async def set_power_mask(self, sid_mask: int, pwr: bool) -> None: """Set the power state of the device.""" packet = bytearray(16) packet[0x00] = 0x0D @@ -392,15 +392,15 @@ def set_power_mask(self, sid_mask: int, pwr: bool) -> None: packet[0x0D] = sid_mask packet[0x0E] = sid_mask if pwr else 0 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - def set_power(self, sid: int, pwr: bool) -> None: + async def set_power(self, sid: int, pwr: bool) -> None: """Set the power state of the device.""" sid_mask = 0x01 << (sid - 1) - self.set_power_mask(sid_mask, pwr) + await self.set_power_mask(sid_mask, pwr) - def check_power_raw(self) -> int: + async def check_power_raw(self) -> int: """Return the power state of the device in raw format.""" packet = bytearray(16) packet[0x00] = 0x0A @@ -412,14 +412,14 @@ def check_power_raw(self) -> int: packet[0x07] = 0xC0 packet[0x08] = 0x01 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return payload[0x0E] - def check_power(self) -> dict: + async def check_power(self) -> dict: """Return the power state of the device.""" - data = self.check_power_raw() + data = await self.check_power_raw() return { "s1": bool(data & 1), "s2": bool(data & 2), @@ -433,7 +433,7 @@ class mp1s(mp1): TYPE = "MP1S" - def get_state(self) -> dict: + async def get_state(self) -> dict: """Return the power state of the device. voltage in V. @@ -452,7 +452,7 @@ def get_state(self) -> dict: packet[0x08] = 0x01 packet[0x0A] = 0x04 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) payload_str = payload.hex()[4:-6] diff --git a/cli/broadlink_cli b/cli/broadlink_cli old mode 100755 new mode 100644 index 7913e332..1014986b --- a/cli/broadlink_cli +++ b/cli/broadlink_cli @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import asyncio import base64 import time from typing import List @@ -57,165 +58,173 @@ parser.add_argument("--joinwifi", nargs=2, help="Args are SSID PASSPHRASE to con parser.add_argument("data", nargs='*', help="Data to send or convert") args = parser.parse_args() -if args.device: - values = args.device.split() - devtype = int(values[0], 0) - host = values[1] - mac = bytearray.fromhex(values[2]) -elif args.mac: - devtype = args.type - host = args.host - mac = bytearray.fromhex(args.mac) - -if args.host or args.device: - dev = broadlink.gendevice(devtype, (host, DEFAULT_PORT), mac) - dev.auth() - -if args.joinwifi: - broadlink.setup(args.joinwifi[0], args.joinwifi[1], 4) - -if args.convert: - data = bytearray.fromhex(''.join(args.data)) - pulses = data_to_pulses(data) - print(format_pulses(pulses)) -if args.temperature: - print(dev.check_temperature()) -if args.humidity: - print(dev.check_humidity()) -if args.energy: - print(dev.get_energy()) -if args.sensors: - data = dev.check_sensors() - for key in data: - print("{} {}".format(key, data[key])) -if args.send: - data = ( - pulses_to_data(parse_pulses(args.data)) - if args.durations - else bytes.fromhex(''.join(args.data)) - ) - dev.send_data(data) -if args.learn or (args.learnfile and not args.rflearn): - dev.enter_learning() - print("Learning...") - start = time.time() - while time.time() - start < TIMEOUT: - time.sleep(1) - try: - data = dev.check_data() - except (ReadError, StorageError): - continue - else: - break - else: - print("No data received...") - exit(1) - - print("Packet found!") - raw_fmt = data.hex() - base64_fmt = base64.b64encode(data).decode('ascii') - pulse_fmt = format_pulses(data_to_pulses(data)) - - print("Raw:", raw_fmt) - print("Base64:", base64_fmt) - print("Pulses:", pulse_fmt) - - if args.learnfile: - print("Saving to {}".format(args.learnfile)) - with open(args.learnfile, "w") as text_file: - text_file.write(pulse_fmt if args.durations else raw_fmt) -if args.check: - if dev.check_power(): - print('* ON *') - else: - print('* OFF *') -if args.checknl: - if dev.check_nightlight(): - print('* ON *') - else: - print('* OFF *') -if args.turnon: - dev.set_power(True) - if dev.check_power(): - print('== Turned * ON * ==') - else: - print('!! Still OFF !!') -if args.turnoff: - dev.set_power(False) - if dev.check_power(): - print('!! Still ON !!') - else: - print('== Turned * OFF * ==') -if args.turnnlon: - dev.set_nightlight(True) - if dev.check_nightlight(): - print('== Turned * ON * ==') - else: - print('!! Still OFF !!') -if args.turnnloff: - dev.set_nightlight(False) - if dev.check_nightlight(): - print('!! Still ON !!') - else: - print('== Turned * OFF * ==') -if args.switch: - if dev.check_power(): - dev.set_power(False) - print('* Switch to OFF *') - else: - dev.set_power(True) - print('* Switch to ON *') -if args.rflearn: - if args.frequency: - frequency = args.frequency - print("Press the button you want to learn, a short press...") - else: - dev.sweep_frequency() - print("Detecting radiofrequency, press and hold the button to learn...") +async def main(): + dev = None + + if args.device: + values = args.device.split() + devtype = int(values[0], 0) + host = values[1] + mac = bytearray.fromhex(values[2]) + elif args.mac: + devtype = args.type + host = args.host + mac = bytearray.fromhex(args.mac) + + if args.host or args.device: + dev = broadlink.gendevice(devtype, (host, DEFAULT_PORT), mac) + await dev.auth() + + if args.joinwifi: + await broadlink.setup(args.joinwifi[0], args.joinwifi[1], 4) + + if args.convert: + data = bytearray.fromhex(''.join(args.data)) + pulses = data_to_pulses(data) + print(format_pulses(pulses)) + if args.temperature: + print(await dev.check_temperature()) + if args.humidity: + print(await dev.check_humidity()) + if args.energy: + print(await dev.get_energy()) + if args.sensors: + data = await dev.check_sensors() + for key in data: + print("{} {}".format(key, data[key])) + if args.send: + data = ( + pulses_to_data(parse_pulses(args.data)) + if args.durations + else bytes.fromhex(''.join(args.data)) + ) + await dev.send_data(data) + if args.learn or (args.learnfile and not args.rflearn): + await dev.enter_learning() + print("Learning...") start = time.time() while time.time() - start < TIMEOUT: - time.sleep(1) - locked, frequency = dev.check_frequency() - if locked: + await asyncio.sleep(1) + try: + data = await dev.check_data() + except (ReadError, StorageError): + continue + else: break else: - print("Radiofrequency not found") - dev.cancel_sweep_frequency() + print("No data received...") exit(1) - print("Radiofrequency detected: {}MHz".format(frequency)) - print("You can now let go of the button") + print("Packet found!") + raw_fmt = data.hex() + base64_fmt = base64.b64encode(data).decode('ascii') + pulse_fmt = format_pulses(data_to_pulses(data)) + + print("Raw:", raw_fmt) + print("Base64:", base64_fmt) + print("Pulses:", pulse_fmt) + + if args.learnfile: + print("Saving to {}".format(args.learnfile)) + with open(args.learnfile, "w") as text_file: + text_file.write(pulse_fmt if args.durations else raw_fmt) + if args.check: + if await dev.check_power(): + print('* ON *') + else: + print('* OFF *') + if args.checknl: + if await dev.check_nightlight(): + print('* ON *') + else: + print('* OFF *') + if args.turnon: + await dev.set_power(True) + if await dev.check_power(): + print('== Turned * ON * ==') + else: + print('!! Still OFF !!') + if args.turnoff: + await dev.set_power(False) + if await dev.check_power(): + print('!! Still ON !!') + else: + print('== Turned * OFF * ==') + if args.turnnlon: + await dev.set_nightlight(True) + if await dev.check_nightlight(): + print('== Turned * ON * ==') + else: + print('!! Still OFF !!') + if args.turnnloff: + await dev.set_nightlight(False) + if await dev.check_nightlight(): + print('!! Still ON !!') + else: + print('== Turned * OFF * ==') + if args.switch: + if await dev.check_power(): + await dev.set_power(False) + print('* Switch to OFF *') + else: + await dev.set_power(True) + print('* Switch to ON *') + if args.rflearn: + if args.frequency: + frequency = args.frequency + print("Press the button you want to learn, a short press...") + else: + await dev.sweep_frequency() + print("Detecting radiofrequency, press and hold the button to learn...") + + start = time.time() + while time.time() - start < TIMEOUT: + await asyncio.sleep(1) + locked, frequency = await dev.check_frequency() + if locked: + break + else: + print("Radiofrequency not found") + await dev.cancel_sweep_frequency() + exit(1) - input("Press enter to continue...") + print("Radiofrequency detected: {}MHz".format(frequency)) + print("You can now let go of the button") - print("Press the button again, now a short press.") + input("Press enter to continue...") - dev.find_rf_packet(frequency) + print("Press the button again, now a short press.") - start = time.time() - while time.time() - start < TIMEOUT: - time.sleep(1) - try: - data = dev.check_data() - except (ReadError, StorageError): - continue + await dev.find_rf_packet(frequency) + + start = time.time() + while time.time() - start < TIMEOUT: + await asyncio.sleep(1) + try: + data = await dev.check_data() + except (ReadError, StorageError): + continue + else: + break else: - break - else: - print("No data received...") - exit(1) - - print("Packet found!") - raw_fmt = data.hex() - base64_fmt = base64.b64encode(data).decode('ascii') - pulse_fmt = format_pulses(data_to_pulses(data)) - - print("Raw:", raw_fmt) - print("Base64:", base64_fmt) - print("Pulses:", pulse_fmt) - - if args.learnfile: - print("Saving to {}".format(args.learnfile)) - with open(args.learnfile, "w") as text_file: - text_file.write(pulse_fmt if args.durations else raw_fmt) + print("No data received...") + exit(1) + + print("Packet found!") + raw_fmt = data.hex() + base64_fmt = base64.b64encode(data).decode('ascii') + pulse_fmt = format_pulses(data_to_pulses(data)) + + print("Raw:", raw_fmt) + print("Base64:", base64_fmt) + print("Pulses:", pulse_fmt) + + if args.learnfile: + print("Saving to {}".format(args.learnfile)) + with open(args.learnfile, "w") as text_file: + text_file.write(pulse_fmt if args.durations else raw_fmt) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cli/broadlink_discovery b/cli/broadlink_discovery old mode 100755 new mode 100644 index 477e1bd7..779a1d21 --- a/cli/broadlink_discovery +++ b/cli/broadlink_discovery @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import asyncio import broadlink from broadlink.const import DEFAULT_BCAST_ADDR, DEFAULT_TIMEOUT @@ -11,20 +12,26 @@ parser.add_argument("--ip", default=None, help="ip address to use in the discove parser.add_argument("--dst-ip", default=DEFAULT_BCAST_ADDR, help="destination ip address to use in the discovery") args = parser.parse_args() -print("Discovering...") -devices = broadlink.discover(timeout=args.timeout, local_ip_address=args.ip, discover_ip_address=args.dst_ip) -for device in devices: - if device.auth(): - print("###########################################") - print(device.type) - print("# broadlink_cli --type {} --host {} --mac {}".format(hex(device.devtype), device.host[0], - ''.join(format(x, '02x') for x in device.mac))) - print("Device file data (to be used with --device @filename in broadlink_cli) : ") - print("{} {} {}".format(hex(device.devtype), device.host[0], ''.join(format(x, '02x') for x in device.mac))) - try: - print("temperature = {}".format(device.check_temperature())) - except (AttributeError, StorageError): - pass - print("") - else: - print("Error authenticating with device : {}".format(device.host)) + +async def main(): + print("Discovering...") + devices = await broadlink.discover(timeout=args.timeout, local_ip_address=args.ip, discover_ip_address=args.dst_ip) + for device in devices: + if await device.auth(): + print("###########################################") + print(device.type) + print("# broadlink_cli --type {} --host {} --mac {}".format(hex(device.devtype), device.host[0], + ''.join(format(x, '02x') for x in device.mac))) + print("Device file data (to be used with --device @filename in broadlink_cli) : ") + print("{} {} {}".format(hex(device.devtype), device.host[0], ''.join(format(x, '02x') for x in device.mac))) + try: + print("temperature = {}".format(await device.check_temperature())) + except (AttributeError, StorageError): + pass + print("") + else: + print("Error authenticating with device : {}".format(device.host)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..52abb677 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-broadlink" +version = "1.0.0.dev0" +description = "Python API for controlling Broadlink devices" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.13" +authors = [ + { name = "Matthew Garrett", email = "mjg59@srcf.ucam.org" }, + { name = "DAB-LABS" }, +] +maintainers = [ + { name = "DAB-LABS" }, +] +keywords = ["broadlink", "infrared", "rf", "home-assistant", "rm4", "rm-pro"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Home Automation", +] +dependencies = [ + "cryptography>=43", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "ruff>=0.5", + "build", +] + +[project.urls] +Homepage = "https://github.com/DAB-LABS/python-broadlink" +Repository = "https://github.com/DAB-LABS/python-broadlink" +Issues = "https://github.com/DAB-LABS/python-broadlink/issues" +Changelog = "https://github.com/DAB-LABS/python-broadlink/blob/master/CHANGELOG.md" +Upstream = "https://github.com/mjg59/python-broadlink" + +[tool.setuptools] +packages = ["broadlink"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 90 +target-version = "py313" + +[tool.ruff.lint] +# Start from the upstream flake8 gate (syntax errors and undefined names) +# plus pyflakes and import hygiene. Style rules widen once the async port lands. +select = ["E9", "F", "I"] + +[tool.ruff.lint.per-file-ignores] +# The package __init__ re-exports the public API. +"broadlink/__init__.py" = ["F401"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2c6c996c..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -cryptography==3.2 diff --git a/setup.py b/setup.py deleted file mode 100644 index 0426f148..00000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -from setuptools import setup, find_packages - - -version = '0.19.0' - -setup( - name="broadlink", - version=version, - author="Matthew Garrett", - author_email="mjg59@srcf.ucam.org", - url="http://github.com/mjg59/python-broadlink", - packages=find_packages(), - scripts=[], - install_requires=["cryptography>=3.2"], - description="Python API for controlling Broadlink devices", - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - ], - include_package_data=True, - zip_safe=False, -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/oracle/__init__.py b/tests/oracle/__init__.py new file mode 100644 index 00000000..40616ad4 --- /dev/null +++ b/tests/oracle/__init__.py @@ -0,0 +1 @@ +"""Byte-level oracle for the device classes. See harness.py.""" diff --git a/tests/oracle/cases.py b/tests/oracle/cases.py new file mode 100644 index 00000000..df782acb --- /dev/null +++ b/tests/oracle/cases.py @@ -0,0 +1,402 @@ +"""The oracle cases: one entry per public method per device class. + +Each case names a device class and product id, a method with arguments, and +the canned response payloads (plaintext, before encryption) the fake device +answers with, in order. ``record.py`` runs them against the library and +freezes the outcome in ``fixtures.json``; ``test_oracle.py`` replays them +and compares. + +Canned payloads are shaped for each class's decoder so the method exercises +its full parse path. Comments say which bytes each decoder reads. +""" + +from __future__ import annotations + +import json +import struct + +from broadlink.helpers import CRC16 + + +def hexb(*parts: bytes | bytearray) -> str: + return b"".join(bytes(p) for p in parts).hex() + + +def b(value: bytes | bytearray) -> dict: + """Wrap bytes for JSON storage.""" + return {"__bytes__": bytes(value).hex()} + + +# ---------------------------------------------------------------- payload builders + + +def rmmini_payload(body: bytes) -> str: + """rmmini._send returns payload[4:]; the first four bytes echo the command.""" + return hexb(b"\x01\x00\x00\x00", body) + + +def rmminib_payload(body: bytes) -> str: + """rmminib._send reads p_len at [0:2] and returns payload[6:p_len+2].""" + p_len = len(body) + 4 + return hexb(struct.pack(" str: + """hysen.send_request: [len][body][crc16(body)]; returns body.""" + p_len = len(body) + 2 + return hexb(struct.pack(" str: + """hvac._decode: [len][bb 00 07 00 00 00][d_len][data][crc16 poly 0x9BE4].""" + p_len = 10 + len(data) + head = struct.pack(" str: + """12-byte header: js_len at 0x08, JSON at 0x0C (sp4, lb2, s3).""" + data = json.dumps(state, separators=(",", ":")).encode() + head = struct.pack(" str: + """14-byte header: js_len at 0x0A, JSON at 0x0E (sp4b, bg1, lb1).""" + data = json.dumps(state, separators=(",", ":")).encode() + head = struct.pack(" str: + p = bytearray(0x10) + p[0x04], p[0x05] = temp + p[0x06], p[0x07] = hum + p[0x08] = light + p[0x0A] = air + p[0x0C] = noise + return p.hex() + + +def a2_payload() -> str: + p = bytearray(0x18) + p[0x0D:0x0F] = (12).to_bytes(2, "big") # pm10 + p[0x0F:0x11] = (7).to_bytes(2, "big") # pm2_5 + p[0x11:0x13] = (3).to_bytes(2, "big") # pm1 + p[0x13:0x15] = (235).to_bytes(2, "big") # temperature + p[0x15:0x17] = (452).to_bytes(2, "big") # humidity + return p.hex() + + +def mp1s_payload() -> str: + """mp1s.get_state slices payload.hex()[4:-6] and reads BCD digit pairs.""" + digits = "".join(str(i % 10) for i in range(54)) + return "0000" + digits + "000000" + + +def s1c_payload() -> str: + def sensor(status, order, stype, name, serial): + s = bytearray(83) + s[0] = status + s[1] = order + s[3] = stype + s[4 : 4 + len(name)] = name.encode() + s[26:30] = serial + return bytes(s) + + p = bytearray(6) + p[4] = 2 + return hexb( + p, + sensor(1, 1, 0x31, "Front door", b"\x01\x02\x03\x04"), + sensor(0, 2, 0x21, "Hall", b"\x0a\x0b\x0c\x0d"), + sensor(0, 3, 0x91, "", b"\x00\x00\x00\x00"), # empty serial: filtered out + ) + + +def hysen_status_body() -> bytes: + body = bytearray(48) + body[3] = 0x01 # remote_lock + body[4] = 0b1101_0001 # heating_cooling=1, temp_manual=1, active=1, offset add=0, power=1 + body[5] = 43 # room temp 21.5 + body[6] = 44 # thermostat temp 22.0 + body[7] = 0x21 # loop_mode 2, auto_mode 1 + body[8] = 0 # sensor + body[9] = 42 # osv + body[10] = 2 # dif + body[11] = 35 # svh + body[12] = 5 # svl + body[13:15] = (-5).to_bytes(2, "big", signed=True) # room_temp_adj -0.5 + body[15] = 0 # fre + body[16] = 1 # poweron + body[17] = 0x20 # unknown (offset raw 2) + body[18] = 50 # external temp 25.0 + body[19], body[20], body[21], body[22] = 14, 30, 5, 3 + for i in range(8): + body[2 * i + 23] = 6 + i + body[2 * i + 24] = 15 + body[i + 39] = 40 + i + return bytes(body) + + +def hvac_state_data() -> bytes: + data = bytearray(2 + 13) + s = memoryview(data)[2:] + s[0x00] = (int(24) - 8 << 3) | 2 # target 24, swing_v POS2 + s[0x01] = (7 << 5) | 0b100 # swing_h OFF + s[0x03] = 2 << 5 # speed MID + s[0x04] = 1 << 6 # preset TURBO (bits 6-7; bit 7 doubles as the half degree) + s[0x05] = (1 << 5) | (1 << 2) # mode COOL, sleep + s[0x08] = (1 << 5) | (1 << 2) | 0b11 # power, clean, health + s[0x0A] = (1 << 4) # display + return bytes(data) + + +def hvac_info_data() -> bytes: + data = bytearray(2 + 22) + s = memoryview(data)[2:] + s[0x01] = 1 + s[0x05] = 26 + s[0x15] = 5 + return bytes(data) + + +def fw_payload(version: int) -> str: + p = bytearray(8) + p[4:6] = version.to_bytes(2, "little") + return p.hex() + + +def rm_update_payload(name: str, locked: bool) -> str: + body = bytearray(0x88) + body[0x48 : 0x48 + len(name)] = name.encode() + body[0x87] = int(locked) + return rmmini_payload(bytes(body)) + + +def rmminib_update_payload(name: str, locked: bool) -> str: + body = bytearray(0x88) + body[0x48 : 0x48 + len(name)] = name.encode() + body[0x87] = int(locked) + return rmminib_payload(bytes(body)) + + +IR_CODE = bytes.fromhex("2600180012341234123412340d05") +EMPTY = "00" * 16 + +# ------------------------------------------------------------------------ cases + + +def case(cls, devtype, method, *args, responses=(), attrs=(), setup=None, **kwargs): + entry = { + "cls": cls, + "devtype": devtype, + "method": method, + "args": list(args), + "kwargs": kwargs, + "responses": list(responses), + } + if attrs: + entry["attrs"] = list(attrs) + if setup: + entry["setup"] = setup + return entry + + +def all_cases() -> list[dict]: + cases: list[dict] = [] + add = cases.append + + # Device base ------------------------------------------------------- + add(case("Device", 0x0000, "get_fwversion", responses=[fw_payload(0x1234)])) + add(case("Device", 0x0000, "set_name", "Living room", responses=[EMPTY], attrs=["name"])) + add(case("Device", 0x0000, "set_lock", True, responses=[EMPTY], attrs=["is_locked"])) + add(case("Device", 0x0000, "set_lock", False, responses=[EMPTY], attrs=["is_locked"], + setup={"name": "Kitchen"})) + add(case("Device", 0x0000, "get_type")) + + # RM family --------------------------------------------------------- + for cls, devtype, payload in (("rmmini", 0x2737, rmmini_payload), + ("rmpro", 0x272A, rmmini_payload), + ("rmminib", 0x5F36, rmminib_payload), + ("rm4mini", 0x51DA, rmminib_payload), + ("rm4pro", 0x6026, rmminib_payload), + ("rm", 0x2712, rmmini_payload), + ("rm4", 0x62BE, rmminib_payload)): + add(case(cls, devtype, "send_data", b(IR_CODE), responses=[payload(b"")])) + add(case(cls, devtype, "enter_learning", responses=[payload(b"")])) + add(case(cls, devtype, "check_data", responses=[payload(IR_CODE)])) + upd = rmminib_update_payload if payload is rmminib_payload else rm_update_payload + add(case(cls, devtype, "update", responses=[upd("Bedroom RM", True)], + attrs=["name", "is_locked"])) + + for cls, devtype in (("rmpro", 0x272A), ("rm", 0x2712)): + add(case(cls, devtype, "check_sensors", responses=[rmmini_payload(bytes([23, 4]))])) + add(case(cls, devtype, "check_temperature", responses=[rmmini_payload(bytes([23, 4]))])) + + for cls, devtype in (("rm4mini", 0x51DA), ("rm4pro", 0x6026), ("rm4", 0x62BE)): + body = bytes([24, 35, 51, 20]) + add(case(cls, devtype, "check_sensors", responses=[rmminib_payload(body)])) + add(case(cls, devtype, "check_temperature", responses=[rmminib_payload(body)])) + add(case(cls, devtype, "check_humidity", responses=[rmminib_payload(body)])) + + for cls, devtype, payload in (("rmpro", 0x272A, rmmini_payload), + ("rm4pro", 0x6026, rmminib_payload), + ("rm", 0x2712, rmmini_payload), + ("rm4", 0x62BE, rmminib_payload)): + add(case(cls, devtype, "sweep_frequency", responses=[payload(b"")])) + found = bytes([1]) + struct.pack(" list[dict]: + """Cases whose canned response carries a device error code.""" + return [ + {"cls": "rmmini", "devtype": 0x2737, "method": "enter_learning", + "args": [], "kwargs": {}, "responses": [EMPTY], "error_code": 0xFFFB}, + {"cls": "sp2", "devtype": 0x2711, "method": "check_power", + "args": [], "kwargs": {}, "responses": [EMPTY], "error_code": 0xFFF9}, + ] diff --git a/tests/oracle/fixtures.json b/tests/oracle/fixtures.json new file mode 100644 index 00000000..782b2bae --- /dev/null +++ b/tests/oracle/fixtures.json @@ -0,0 +1,4099 @@ +[ + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "get_fwversion", + "args": [], + "kwargs": {}, + "responses": [ + "0000000034120000" + ] + }, + "expect": { + "result": 4660, + "sent": [ + [ + 106, + "68" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_name", + "args": [ + "Living room" + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "name" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "000000004c6976696e6720726f6f6d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Living room" + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_lock", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0000000042656e63680000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "is_locked": true + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_lock", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "is_locked" + ], + "setup": { + "name": "Kitchen" + } + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "000000004b69746368656e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "is_locked": false + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "get_type", + "args": [], + "kwargs": {}, + "responses": [] + }, + "expect": { + "result": "Unknown", + "sent": [], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": { + "temperature": 23.4 + }, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": 23.4, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": { + "temperature": 23.4 + }, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": 23.4, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "19000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0100000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "010000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040019000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "09000000000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0900000000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08001b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "19000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0100000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "010000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040019000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "09000000000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0900000000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08001b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp1", + "devtype": 0, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 102, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp1", + "devtype": 0, + "method": "set_power", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 102, + "00000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "02000000010000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "02000000010000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2s", + "devtype": 10024, + "method": "get_energy", + "args": [], + "kwargs": {}, + "responses": [ + "00000000d20400000000000000000000" + ] + }, + "expect": { + "result": 1.234, + "sent": [ + [ + 106, + "04000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "get_energy", + "args": [], + "kwargs": {}, + "responses": [ + "00000000003412000000000000000000" + ] + }, + "expect": { + "result": 12.34, + "sent": [ + [ + 106, + "0800fe0105010000002d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000", + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ], + [ + 106, + "02000000030000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "set_nightlight", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000", + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ], + [ + 106, + "02000000030000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "check_nightlight", + "args": [], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "a5a55a5ac3c3020b090000007b22707772223a317d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_nightlight", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "a5a55a5a67c5020b0d0000007b226e746c69676874223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": true, + "ntlbrightness": 25, + "childlock": true + }, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0 + }, + "sent": [ + [ + 106, + "a5a55a5a04cf020b2a0000007b22707772223a312c226e746c6272696768746e657373223a32352c226368696c646c6f636b223a317d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "check_nightlight", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0, + "current": 0.12, + "volt": 230.5, + "power": 27.6, + "overload": 0.0 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": false + }, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0, + "current": 120, + "volt": 230500, + "power": 27600, + "totalconsum": -1, + "overload": 0 + }, + "sent": [ + [ + 106, + "1500a5a55a5ac2c3020b090000007b22707772223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "bg1", + "devtype": 20963, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "bg1", + "devtype": 20963, + "method": "set_state", + "args": [], + "kwargs": { + "pwr1": true, + "maxworktime2": 15 + }, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "2b00a5a55a5a64ca020b1f0000007b2270777231223a20312c20226d6178776f726b74696d6532223a2031357d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "ehc31", + "devtype": 25728, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "ehc31", + "devtype": 25728, + "method": "set_state", + "args": [], + "kwargs": { + "pwr3": true, + "childlock": true, + "childlock4": false + }, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "3800a5a55a5afccd020b2c0000007b2270777233223a20312c20226368696c646c6f636b223a20312c20226368696c646c6f636b34223a20307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power_mask", + "args": [ + 5, + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5abcc00200030000050500" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power", + "args": [ + 1, + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5ab4c00200030000010100" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power", + "args": [ + 3, + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5ab6c00200030000040000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "check_power_raw", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": 11, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": { + "s1": true, + "s2": true, + "s3": false, + "s4": true + }, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1s", + "devtype": 20251, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "0000012345678901234567890123456789012345678901234567890123000000" + ] + }, + "expect": { + "result": { + "volt": 230.1, + "current": 89.6745, + "power": 4523.01, + "totalconsum": 230189.67 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab2c00100040000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1s", + "devtype": 20251, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": { + "s1": true, + "s2": true, + "s3": false, + "s4": true + }, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020200010000000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": "normal", + "air_quality": "good", + "noise": "quiet" + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020900090009000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": "unknown", + "air_quality": "unknown", + "noise": "unknown" + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors_raw", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020200010000000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": 2, + "air_quality": 1, + "noise": 0 + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a2", + "devtype": 20320, + "method": "check_sensors_raw", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000c0007000300eb01c400" + ] + }, + "expect": { + "result": { + "temperature": 235, + "humidity": 452, + "pm10": 12, + "pm2_5": 7, + "pm1": 3 + }, + "sent": [ + [ + 106, + "0a00a5a55a5ab9c0010b0000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb1", + "devtype": 24775, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "e500a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb1", + "devtype": 24775, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": true, + "brightness": 50, + "bulb_colormode": 1, + "bulb_scene": "" + }, + "responses": [ + "e500a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "4800a5a55a5ae1d4020b3c0000007b22707772223a312c226272696768746e657373223a35302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e65223a22227d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb2", + "devtype": 42228, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb2", + "devtype": 42228, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": false, + "red": 1, + "green": 2, + "blue": 3, + "transitionduration": 200 + }, + "responses": [ + "a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "a5a55a5a6bd4020b3d0000007b22707772223a302c22726564223a312c22626c7565223a332c22677265656e223a322c227472616e736974696f6e6475726174696f6e223a3230307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "send_request", + "args": [ + [ + 1, + 3, + 0, + 0, + 0, + 8 + ] + ], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": { + "__bytes__": "00000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00" + }, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": 21.5, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_external_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": 25.0, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_full_status", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": { + "remote_lock": 1, + "power": 1, + "active": 1, + "temp_manual": 1, + "heating_cooling": 1, + "room_temp": 21.5, + "thermostat_temp": 22.0, + "auto_mode": 1, + "loop_mode": 2, + "sensor": 0, + "osv": 42, + "dif": 2, + "svh": 35, + "svl": 5, + "room_temp_adj": -0.5, + "fre": 0, + "poweron": 1, + "unknown": 32, + "external_temp": 25.0, + "hour": 14, + "min": 30, + "sec": 5, + "dayofweek": 3, + "weekday": [ + { + "start_hour": 6, + "start_minute": 15, + "temp": 20.0 + }, + { + "start_hour": 7, + "start_minute": 15, + "temp": 20.5 + }, + { + "start_hour": 8, + "start_minute": 15, + "temp": 21.0 + }, + { + "start_hour": 9, + "start_minute": 15, + "temp": 21.5 + }, + { + "start_hour": 10, + "start_minute": 15, + "temp": 22.0 + }, + { + "start_hour": 11, + "start_minute": 15, + "temp": 22.5 + } + ], + "weekend": [ + { + "start_hour": 12, + "start_minute": 15, + "temp": 23.0 + }, + { + "start_hour": 13, + "start_minute": 15, + "temp": 23.5 + } + ] + }, + "sent": [ + [ + 106, + "0800010300000016c404" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_mode", + "args": [ + 1, + 2 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08000106000231003d9a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_mode", + "args": [ + 0, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0800010600021001e40a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_advanced", + "args": [ + 0, + 0, + 42, + 2, + 35, + 5, + -0.5, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "13000110000200050a00002a022305fffb0001e8eb" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "switch_to_auto", + "args": [], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0800010600021100245a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "switch_to_manual", + "args": [], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060002100025ca" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_temp", + "args": [ + 21.5 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060001002b9815" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_power", + "args": [ + 1, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060000008149aa" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_time", + "args": [ + 14, + 30, + 5, + 3 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00011000080002040e1e0503d3b6" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_schedule", + "args": [ + [ + { + "start_hour": 6, + "start_minute": 15, + "temp": 20 + }, + { + "start_hour": 7, + "start_minute": 15, + "temp": 21 + }, + { + "start_hour": 8, + "start_minute": 15, + "temp": 22 + }, + { + "start_hour": 9, + "start_minute": 15, + "temp": 23 + }, + { + "start_hour": 10, + "start_minute": 15, + "temp": 24 + }, + { + "start_hour": 11, + "start_minute": 15, + "temp": 25 + } + ], + [ + { + "start_hour": 8, + "start_minute": 0, + "temp": 21 + }, + { + "start_hour": 22, + "start_minute": 30, + "temp": 17.5 + } + ] + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "21000110000a000c18060f070f080f090f0a0f0b0f0800161e282a2c2e30322a2312ca" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f0045e3" + ] + }, + "expect": { + "error": "DataValidationError: [Errno -4008] Received data packet check error: Expected a checksum of 58181 and received 7237", + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "0c00bb0006800000020011014768" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_ac_info", + "args": [], + "kwargs": {}, + "responses": [ + "2200bb00070000001800000000010000001a00000000000000000000000000000005a5e0" + ] + }, + "expect": { + "result": { + "power": 1, + "ambient_temp": 26.5 + }, + "sent": [ + [ + 106, + "0c00bb0006800000020021018f90" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "0d00bb000700000003000000013273" + ] + }, + "expect": { + "error": "DataValidationError: [Errno -4007] Received data packet length error: Expected at least 15 bytes and received 3", + "sent": [ + [ + 106, + "0c00bb0006800000020011014768" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 22.5, + 1, + 2, + 0, + 7, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "1900bb00068000000f00010170e48d4000200000200010000577f6" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 24, + 4, + 3, + 2, + 0, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "1900bb00068000000f00010180040d608080000020001000058a9b" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 24, + 2, + 1, + 1, + 0, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "error": "ValueError: turbo is only available in cooling/heating", + "sent": [], + "unused_responses": 1 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb010000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb020000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb030000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "get_percentage", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb065d00000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "set_percentage_and_wait", + "args": [ + 50 + ], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000", + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0900bb065d00000000fa440000000000" + ], + [ + 106, + "0900bb030000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5abec0020b03000000000100" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5abfc0020b03000000000200" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5ac0c0020b03000000000300" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "get_percentage", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": 40, + "sent": [ + [ + 106, + "0f00a5a55a5ac2c0010b03000000000600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "set_percentage", + "args": [ + 40 + ], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5aeec0020b03000000000928" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "get_position", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0a00a5a55a5ab9c0010b0000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5ad8c1020b030000004a31a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5af0c1020b030000006132a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5a1cc2020b030000004c73a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "set_position", + "args": [ + 30 + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5aebc1020b030000001e70a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_subdevices", + "args": [ + 2 + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b400000007b22746f74616c223a332c226c697374223a5b7b22646964223a226131222c2270777231223a317d2c7b22646964223a226132222c2270777231223a307d5d7d", + "a5a55a5a0000010b400000007b22746f74616c223a332c226c697374223a5b7b22646964223a226132222c2270777231223a307d2c7b22646964223a226133222c2270777231223a317d5d7d" + ] + }, + "expect": { + "result": [ + { + "did": "a1", + "pwr1": 1 + }, + { + "did": "a2", + "pwr1": 0 + }, + { + "did": "a3", + "pwr1": 1 + } + ], + "sent": [ + [ + 106, + "a5a55a5a9ec70e0b150000007b22636f756e74223a322c22696e646578223a307d" + ], + [ + 106, + "a5a55a5aa0c70e0b150000007b22636f756e74223a322c22696e646578223a327d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b0a0000007b2270777231223a317d" + ] + }, + "expect": { + "result": { + "pwr1": 1 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_state", + "args": [ + "a1" + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b0a0000007b2270777231223a317d" + ] + }, + "expect": { + "result": { + "pwr1": 1 + }, + "sent": [ + [ + 106, + "a5a55a5a42c4010b0c0000007b22646964223a226131227d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "set_state", + "args": [ + "a1", + true, + null, + false + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b130000007b2270777231223a312c2270777233223a307d" + ] + }, + "expect": { + "result": { + "pwr1": 1, + "pwr3": 0 + }, + "sent": [ + [ + 106, + "a5a55a5a20c9020b1e0000007b22646964223a226131222c2270777231223a312c2270777233223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "S1C", + "devtype": 10018, + "method": "get_sensors_status", + "args": [], + "kwargs": {}, + "responses": [ + "0000000002000101003146726f6e7420646f6f720000000000000000000000000102030400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002002148616c6c0000000000000000000000000000000000000a0b0c0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003009100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + }, + "expect": { + "result": { + "count": 2, + "sensors": [ + { + "status": 1, + "name": "Front door", + "type": "Door Sensor", + "order": 1, + "serial": "01020304" + }, + { + "status": 0, + "name": "Hall", + "type": "Motion Sensor", + "order": 2, + "serial": "0a0b0c0d" + } + ] + }, + "sent": [ + [ + 106, + "06000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [] + }, + "expect": { + "error": "AssertionError: method sent more packets than canned responses (1 sent)", + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "error_code": 65531 + }, + "expect": { + "error": "StorageError: [Errno -5] The device storage is full", + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "error_code": 65529 + }, + "expect": { + "error": "AuthorizationError: [Errno -7] Control key is expired", + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + } +] diff --git a/tests/oracle/harness.py b/tests/oracle/harness.py new file mode 100644 index 00000000..9fb8d2d0 --- /dev/null +++ b/tests/oracle/harness.py @@ -0,0 +1,158 @@ +"""Harness that records what device methods send and what they decode. + +Every public method on a device class ends up calling ``Device.send_packet`` +with a packet type and a plaintext payload, and then decoding whatever the +device answers. The transport (framing, encryption, retries) lives in +``send_packet`` itself and is tested separately. This harness replaces +``send_packet`` on one device instance so that: + +- each call is recorded as ``(packet_type, payload)`` before encryption, and +- each call is answered with a well-formed response frame carrying the next + canned payload, encrypted with the device's current session key so that the + method's own ``decrypt`` sees exactly those bytes. + +The recorded sequence and the method's return value are the "oracle": a +later reimplementation of the same method (for example, an asynchronous one) +must produce the same sequence and the same result from the same canned +responses. Results are normalized to plain JSON so they can be stored. + +The runner accepts awaitables so the same cases can drive an asynchronous +``send_packet`` later without changing the cases. +""" + +from __future__ import annotations + +import asyncio +import enum +import inspect +from dataclasses import dataclass, field +from typing import Any + +import broadlink +from broadlink.device import Device + +# A fixed identity so recorded bytes never depend on random state. +MAC = bytes.fromhex("a043b05510f7") +HOST = ("192.0.2.10", 80) + + +def pad16(payload: bytes) -> bytes: + """Pad to the AES block size, as the device does before encrypting.""" + return bytes(payload) + bytes((16 - len(payload)) % 16) + + +def make_response(device: Device, payload: bytes, error: int = 0) -> bytes: + """Build a response frame the way a device would answer ``send_packet``. + + Only the parts the device classes read are meaningful: the error code + at 0x22:0x24 and the encrypted payload from 0x38. The frame checksum is + filled in so the frame would also pass ``send_packet``'s own check. + """ + frame = bytearray(0x38) + frame[0x00:0x08] = bytes.fromhex("5aa5aa555aa5aa55") + frame[0x22:0x24] = (error & 0xFFFF).to_bytes(2, "little") + frame[0x24:0x26] = device.devtype.to_bytes(2, "little") + frame[0x2A:0x30] = device.mac[::-1] + frame.extend(device.encrypt(pad16(payload))) + checksum = sum(frame, 0xBEAF) & 0xFFFF + frame[0x20:0x22] = checksum.to_bytes(2, "little") + return bytes(frame) + + +@dataclass +class Recorder: + """Replacement ``send_packet`` that records requests and serves responses.""" + + device: Device + responses: list[bytes] + error: int = 0 + sent: list[tuple[int, bytes]] = field(default_factory=list) + + def __call__(self, packet_type: int, payload: bytes) -> bytes: + self.sent.append((packet_type, bytes(payload))) + if not self.responses: + raise AssertionError( + f"method sent more packets than canned responses " + f"({len(self.sent)} sent)" + ) + return make_response(self.device, self.responses.pop(0), self.error) + + async def async_call(self, packet_type: int, payload: bytes) -> bytes: + return self(packet_type, payload) + + +def normalize(value: Any) -> Any: + """Turn a method result into plain JSON-compatible data.""" + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, (bytes, bytearray)): + return {"__bytes__": bytes(value).hex()} + if isinstance(value, dict): + return {str(k): normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [normalize(v) for v in value] + if isinstance(value, float): + return round(value, 6) + return value + + +def build_device(cls_name: str, devtype: int) -> Device: + """Instantiate a device class by name with the fixed test identity.""" + cls = getattr(broadlink, cls_name) + return cls(HOST, MAC, devtype, name="Bench", model="Test", manufacturer="Test") + + +def run_case(case: dict) -> dict: + """Execute one case and return the recorded outcome. + + ``case`` has: ``cls``, ``devtype``, ``method``, ``args``, ``kwargs``, + ``responses`` (list of hex payloads), and optionally ``setup`` (attribute + values applied before the call) and ``attrs`` (attribute names to record + after the call). + """ + device = build_device(case["cls"], case["devtype"]) + for name, value in case.get("setup", {}).items(): + setattr(device, name, value) + + responses = [bytes.fromhex(r) for r in case.get("responses", [])] + recorder = Recorder(device, responses, case.get("error_code", 0)) + target = getattr(device, "send_packet") + if inspect.iscoroutinefunction(target): + device.send_packet = recorder.async_call # type: ignore[method-assign] + else: + device.send_packet = recorder # type: ignore[method-assign] + + method = getattr(device, case["method"]) + args = [decode_arg(a) for a in case.get("args", [])] + kwargs = {k: decode_arg(v) for k, v in case.get("kwargs", {}).items()} + + outcome: dict[str, Any] = {} + try: + result = method(*args, **kwargs) + if inspect.isawaitable(result): + result = asyncio.run(_await(result)) + outcome["result"] = normalize(result) + except Exception as err: # noqa: BLE001 - the error type IS the oracle + outcome["error"] = f"{type(err).__name__}: {err}" + + outcome["sent"] = [[ptype, payload.hex()] for ptype, payload in recorder.sent] + outcome["unused_responses"] = len(recorder.responses) + attrs = case.get("attrs", []) + if attrs: + outcome["attrs"] = {a: normalize(getattr(device, a)) for a in attrs} + return outcome + + +async def _await(awaitable): + return await awaitable + + +def decode_arg(value: Any) -> Any: + """Cases store bytes arguments as {"__bytes__": hex}.""" + if isinstance(value, dict) and set(value) == {"__bytes__"}: + return bytes.fromhex(value["__bytes__"]) + if isinstance(value, list): + return [decode_arg(v) for v in value] + if isinstance(value, dict): + return {k: decode_arg(v) for k, v in value.items()} + return value diff --git a/tests/oracle/record.py b/tests/oracle/record.py new file mode 100644 index 00000000..82e8ccc6 --- /dev/null +++ b/tests/oracle/record.py @@ -0,0 +1,38 @@ +"""Record the oracle fixtures from the current library. + +Run from the repository root: + + python -m tests.oracle.record + +This overwrites ``tests/oracle/fixtures.json``. Only run it when the recorded +behavior is meant to change (for example, a deliberate protocol fix), and +review the diff of the fixture file in the same pull request. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from . import cases as case_module +from .harness import run_case + +FIXTURES = Path(__file__).with_name("fixtures.json") + + +def build() -> list[dict]: + entries = [] + for case in case_module.all_cases() + case_module.error_cases(): + outcome = run_case(case) + entries.append({"case": case, "expect": outcome}) + return entries + + +def main() -> None: + entries = build() + FIXTURES.write_text(json.dumps(entries, indent=1) + "\n") + print(f"recorded {len(entries)} cases to {FIXTURES}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 00000000..14ed8c50 --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,548 @@ +"""Capture windows and packet helpers, against a scripted universal remote. + +The fake replaces ``send_packet`` on a real device instance, decodes the +command the class framed, and behaves like an RM4 Pro as measured on the +bench: it holds one code per learning session, answers ``check_data`` with +``StorageError`` -5 until a code lands, and drops presses while not armed. +""" + +from __future__ import annotations + +import asyncio +import struct +from contextlib import aclosing + +import pytest + +import broadlink +from broadlink import exceptions as e +from broadlink.remote import ( + CapturedSignal, + SignalKind, + data_to_pulses, + parse_packet, + pulses_to_data, +) +from tests.oracle.harness import HOST, MAC, make_response + +IR = pulses_to_data([9000, 4500, 560, 560, 560, 1690]) +RF = pulses_to_data([300, 900, 300, 900], kind=SignalKind.RF_433) + +CMD_SEND = 0x02 +CMD_LEARN = 0x03 +CMD_CHECK = 0x04 +CMD_SWEEP = 0x19 +CMD_CHECK_FREQ = 0x1A +CMD_FIND_RF = 0x1B +CMD_CANCEL_SWEEP = 0x1E + + +class FakeRM: + """A scripted RM: one receiver, one code per arm, silent expiry.""" + + def __init__(self, device: broadlink.Device, framing: str) -> None: + self.device = device + self.framing = framing # "rmmini" ( bool: + """A remote is pressed at the device. Captured only while armed.""" + if not self.armed: + return False + self.pending = packet + self.armed = False # One code per learning session. + return True + + def expire(self) -> None: + """The device leaves learning mode without telling anyone.""" + self.armed = False + + # -- fake transport + async def send_packet(self, packet_type: int, payload: bytes) -> bytes: + assert packet_type == 0x6A + if self.framing == "rmmini": + command = struct.unpack(" tuple[bytes, int | str]: + if command == CMD_LEARN: + self.armed = True + self.rf_frequency = None + return b"", 0 + if command == CMD_FIND_RF: + self.armed = True + self.rf_frequency = struct.unpack(" int: + return sum(1 for c, _ in self.commands if c == command) + + +def make(cls_name: str = "rm4pro", devtype: int = 0x649B) -> tuple[broadlink.Device, FakeRM]: + cls = getattr(broadlink, cls_name) + device = cls(HOST, MAC, devtype, name="Bench", model="Test", manufacturer="Test") + framing = "rmmini" if cls_name in {"rmmini", "rmpro", "rm"} else "rmminib" + return device, FakeRM(device, framing) + + +def run(coro): + return asyncio.run(coro) + + +async def press_later(fake: FakeRM, packet: bytes, delay: float) -> bool: + await asyncio.sleep(delay) + return fake.press(packet) + + +FAST = dict(poll_interval=0.01, rearm_interval=10.0) + + +# ------------------------------------------------------------- IR windows + + +@pytest.mark.parametrize("cls_name,devtype", [("rm4pro", 0x649B), ("rmpro", 0x272A), + ("rm4mini", 0x51DA), ("rm5plus", 0x5224)]) +def test_capture_yields_first_signal_and_closes(cls_name, devtype): + device, fake = make(cls_name, devtype) + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, IR, 0.03)) + signals = [s async for s in device.capture(window=2, **FAST)] + return signals + + signals = run(go()) + assert len(signals) == 1 + sig = signals[0] + assert isinstance(sig, CapturedSignal) + assert sig.packet == IR + assert sig.kind is SignalKind.IR + assert sig.pulses == data_to_pulses(IR) + assert sig.frequency_mhz is None + assert fake.commands[0][0] == CMD_LEARN + assert fake.count(CMD_LEARN) == 1 + assert fake.count(CMD_CHECK) >= 2 + assert device._capture_open is False + + +def test_capture_window_elapses_with_nothing(): + device, fake = make() + signals = run(_collect(device.capture(window=0.05, **FAST))) + assert signals == [] + assert fake.count(CMD_LEARN) == 1 + assert fake.count(CMD_CHECK) >= 3 + assert device._capture_open is False + + +async def _collect(gen): + return [s async for s in gen] + + +def test_capture_keeps_going_and_rearms_after_each_code(): + device, fake = make() + + async def go(): + loop = asyncio.get_running_loop() + loop.create_task(press_later(fake, IR, 0.02)) + loop.create_task(press_later(fake, RF, 0.06)) + return [s async for s in device.capture(window=0.12, stop_after_first=False, **FAST)] + + signals = run(go()) + assert [s.packet for s in signals] == [IR, RF] + # Armed once at the start and once after each code. + assert fake.count(CMD_LEARN) == 3 + + +def test_press_between_code_and_rearm_is_lost_but_next_is_not(): + """The device holds one code per session; a second press before the + window re-arms is gone, as measured on the bench.""" + device, fake = make() + + async def go(): + loop = asyncio.get_running_loop() + results = [] + + async def presses(): + await asyncio.sleep(0.02) + results.append(fake.press(IR)) + results.append(fake.press(RF)) # Device not armed: lost. + await asyncio.sleep(0.03) + results.append(fake.press(RF)) # Re-armed by then. + + loop.create_task(presses()) + signals = [s async for s in device.capture(window=0.1, stop_after_first=False, **FAST)] + return results, signals + + results, signals = run(go()) + assert results == [True, False, True] + assert [s.packet for s in signals] == [IR, RF] + + +def test_send_during_window_rearms(): + device, fake = make() + + async def go(): + async def send_then_press(): + await asyncio.sleep(0.02) + await device.send_data(IR) + fake.expire() # Whatever the send did to the session, assume the worst. + await asyncio.sleep(0.03) + return fake.press(RF) + + loop = asyncio.get_running_loop() + task = loop.create_task(send_then_press()) + signals = [s async for s in device.capture(window=0.2, **FAST)] + return await task, signals + + pressed, signals = run(go()) + assert pressed is True + assert [s.packet for s in signals] == [RF] + assert fake.count(CMD_SEND) == 1 + assert fake.count(CMD_LEARN) == 2 + learn_positions = [i for i, (c, _) in enumerate(fake.commands) if c == CMD_LEARN] + send_position = next(i for i, (c, _) in enumerate(fake.commands) if c == CMD_SEND) + assert learn_positions[0] < send_position < learn_positions[1] + + +def test_timed_rearm_recovers_from_silent_expiry(): + device, fake = make() + + async def go(): + async def expire_then_press(): + await asyncio.sleep(0.02) + fake.expire() + assert fake.press(IR) is False # Lost: the device is deaf. + await asyncio.sleep(0.05) # Past the re-arm interval. + return fake.press(IR) + + loop = asyncio.get_running_loop() + task = loop.create_task(expire_then_press()) + signals = [ + s async for s in device.capture(window=0.3, poll_interval=0.01, rearm_interval=0.04) + ] + return await task, signals + + pressed, signals = run(go()) + assert pressed is True + assert len(signals) == 1 + assert fake.count(CMD_LEARN) >= 2 + + +def test_open_ended_window_runs_until_closed(): + device, fake = make() + + async def go(): + got = [] + async with aclosing(device.capture(window=0, stop_after_first=False, **FAST)) as gen: + asyncio.get_running_loop().create_task(press_later(fake, IR, 0.02)) + async for s in gen: + got.append(s) + if len(got) == 1: + break + return got + + got = run(go()) + assert len(got) == 1 + assert device._capture_open is False + # Closing sends nothing further to the device. + assert fake.commands[-1][0] in (CMD_CHECK, CMD_LEARN) + + +def test_second_window_is_refused(): + device, fake = make() + + async def go(): + task = asyncio.get_running_loop().create_task( + _collect(device.capture(window=1, **FAST)) + ) + await asyncio.sleep(0.02) + with pytest.raises(e.CaptureInProgressError): + await _collect(device.capture(window=1, **FAST)) + assert device._capture_open is True + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + run(go()) + assert device._capture_open is False + + +def test_transport_timeouts_rearm_then_give_up(): + device, fake = make() + fake.timeouts_to_raise = 2 + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, IR, 0.05)) + return [s async for s in device.capture(window=1, **FAST)] + + signals = run(go()) + assert len(signals) == 1 + assert fake.count(CMD_LEARN) >= 2 # Re-armed after the timeouts. + + device, fake = make() + fake.timeouts_to_raise = 3 + with pytest.raises(e.NetworkTimeoutError): + run(_collect(device.capture(window=1, **FAST))) + assert device._capture_open is False + + +def test_capture_rejects_bad_arguments(): + device, _ = make() + with pytest.raises(ValueError): + run(_collect(device.capture(window=-1))) + with pytest.raises(ValueError): + run(_collect(device.capture(poll_interval=0))) + with pytest.raises(ValueError): + run(_collect(device.capture(rearm_interval=0))) + + +# ------------------------------------------------------------- RF windows + + +def test_capture_rf_with_known_frequency_skips_the_sweep(): + device, fake = make() + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, RF, 0.03)) + return [s async for s in device.capture_rf(window=1, frequency=433.92, **FAST)] + + signals = run(go()) + assert len(signals) == 1 + sig = signals[0] + assert sig.kind is SignalKind.RF_433 + assert sig.frequency_mhz == 433.92 + assert sig.packet == RF + assert fake.count(CMD_SWEEP) == 0 + assert fake.commands[0] == (CMD_FIND_RF, struct.pack(" kinds.index(CMD_CHECK_FREQ) + assert fake.commands[find][1] == struct.pack(" 0 diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 00000000..27db245f --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,109 @@ +"""Pure helpers: pulse packing, CRC16 and the protocol datetime.""" + +from __future__ import annotations + +import datetime as dt + +import pytest + +from broadlink.helpers import CRC16 +from broadlink.protocol import Datetime +from broadlink.remote import TICK, data_to_pulses, pulses_to_data + + +def test_tick_constant(): + # 32768 Hz timebase: protocol.md's "us * 269 / 8192". + assert TICK == pytest.approx(8192 / 269) + assert TICK == pytest.approx(30.4535, abs=1e-4) + + +def test_pulses_to_data_header_and_short_pulses(): + data = pulses_to_data([328, 656]) + assert data[0] == 0x26 + assert data[1] == 0x00 + assert int.from_bytes(data[2:4], "little") == 2 + # round(328/30.4535)=11, round(656/30.4535)=22 + assert data[4:] == bytes([11, 22]) + + +def test_pulses_to_data_rounds_to_nearest_tick(): + # 0.6 of a tick rounds up; 0.4 rounds down. The old code floored both. + assert pulses_to_data([TICK * 10.6])[4] == 11 + assert pulses_to_data([TICK * 10.4])[4] == 10 + + +def test_pulses_to_data_long_pulse_uses_three_byte_form(): + data = pulses_to_data([10000]) + ticks = round(10000 / TICK) # 328 + assert ticks > 255 + assert data[4:] == bytes([0, ticks >> 8, ticks & 0xFF]) + assert int.from_bytes(data[2:4], "little") == 3 + + +def test_explicit_tick_argument_still_honored(): + # Callers may still pass their own tick. + assert pulses_to_data([328, 656], tick=32.84)[4:] == bytes([10, 20]) + assert data_to_pulses(bytes([0x26, 0, 1, 0, 10]), tick=32.84) == [328] + + +def test_data_to_pulses_round_trip_at_same_tick(): + pulses = [9000, 4500, 560, 560, 560, 1690, 40000] + data = pulses_to_data(pulses) + back = data_to_pulses(data) + # Rounding on the way in (and int() on the way out) keeps the round + # trip within half a tick plus one microsecond. + for a, b in zip(pulses, back, strict=True): + assert abs(a - b) <= TICK / 2 + 1 + + +def test_data_to_pulses_honors_declared_length(): + data = pulses_to_data([328, 656]) + b"\x0d\x05" # trailing terminator bytes + assert len(data_to_pulses(data)) == 2 + + +def test_data_to_pulses_rejects_truncated_long_form(): + with pytest.raises(ValueError): + data_to_pulses(bytes([0x26, 0x00, 0x02, 0x00, 0x00, 0x01])) + + +def test_crc16_known_vector(): + # CRC-16/MODBUS of "123456789" is 0x4B37. + assert CRC16.calculate(b"123456789") == 0x4B37 + assert CRC16.calculate(b"") == 0xFFFF + + +def test_crc16_table_is_cached(): + CRC16._cache.pop(0xA001, None) + t1 = CRC16.get_table(0xA001) + t2 = CRC16.get_table(0xA001) + assert t1 is t2 + assert len(t1) == 256 + + +def test_datetime_pack_layout(): + tz = dt.timezone(dt.timedelta(hours=-7)) + when = dt.datetime(2026, 9, 4, 14, 30, 0, tzinfo=tz) + data = Datetime.pack(when) + assert len(data) == 12 + assert int.from_bytes(data[0:4], "little", signed=True) == -7 + assert int.from_bytes(data[4:6], "little") == 2026 + assert data[6] == 30 + assert data[7] == 14 + assert data[8] == 26 + assert data[9] == 5 # Friday + assert data[10] == 4 + assert data[11] == 9 + + +def test_datetime_round_trip_and_validation(): + tz = dt.timezone(dt.timedelta(hours=2)) + when = dt.datetime(2026, 1, 15, 8, 5, 0, tzinfo=tz) + data = bytearray(Datetime.pack(when)) + assert Datetime.unpack(bytes(data)) == when + data[9] = 1 # wrong weekday + with pytest.raises(ValueError): + Datetime.unpack(bytes(data)) + + +def test_datetime_now_has_tzinfo(): + assert Datetime.now().tzinfo is not None diff --git a/tests/test_oracle.py b/tests/test_oracle.py new file mode 100644 index 00000000..6cdd4d2b --- /dev/null +++ b/tests/test_oracle.py @@ -0,0 +1,57 @@ +"""Replay the recorded oracle: every device method must send the same bytes +and decode the same result it did when the fixtures were recorded.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tests.oracle.harness import run_case + +FIXTURES = Path(__file__).parent / "oracle" / "fixtures.json" +ENTRIES = json.loads(FIXTURES.read_text()) + + +def _ident(entry: dict) -> str: + c = entry["case"] + return f"{c['cls']}.{c['method']}" + + +@pytest.mark.parametrize("entry", ENTRIES, ids=[_ident(e) for e in ENTRIES]) +def test_oracle(entry: dict) -> None: + outcome = run_case(entry["case"]) + assert outcome == entry["expect"] + + +def test_every_public_method_is_covered() -> None: + """Fail when a device class grows a public method the oracle does not know.""" + import inspect + + import broadlink + from broadlink.device import Device + + covered = {(e["case"]["cls"], e["case"]["method"]) for e in ENTRIES} + # Methods on Device itself that need a live socket are covered in + # test_transport.py, not here. + transport_level = {"auth", "hello", "ping", "send_packet", "encrypt", "decrypt", + "update_aes", "aclose"} + # Capture windows drive several requests over time; they are covered + # with a scripted device in test_capture.py. + transport_level |= {"capture", "capture_rf"} + missing = [] + for name, cls in inspect.getmembers(broadlink, inspect.isclass): + if not issubclass(cls, Device): + continue + for meth, _ in inspect.getmembers(cls, inspect.isfunction): + if meth.startswith("_") or meth in transport_level: + continue + # Inherited methods are covered on the class that defines them + # or on a subclass case; require at least one case per class/method + # pair where the method is defined on that class. + if meth not in cls.__dict__: + continue + if (name, meth) not in covered: + missing.append(f"{name}.{meth}") + assert not missing, f"public methods without an oracle case: {missing}" diff --git a/tests/test_remote.py b/tests/test_remote.py new file mode 100644 index 00000000..c0f7f06a --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,63 @@ +"""Tests for the tick constant used by pulses_to_data / data_to_pulses (GH #839).""" +import unittest + +from broadlink.remote import TICK, data_to_pulses, pulses_to_data + +OLD_TICK = 32.84 # the constant this PR replaces + + +class TestTickConstant(unittest.TestCase): + """TICK must match protocol.md's worked examples (its "us * 269 / 8192" formula).""" + + def test_tick_matches_protocol_md(self): + # protocol.md's literal, verified-by-example formula. + self.assertAlmostEqual(TICK, 8192 / 269, places=4) + + def test_protocol_md_worked_examples(self): + # protocol.md's own worked examples, applying its own formula + # (us * 269 / 8192) literally: 8920 us -> 0x124 (292 ticks), + # 4450 us -> 0x92 (146 ticks). TICK = 8192 / 269 reproduces both + # exactly; the alternative reading "2^-15 s" (1e6 / 2**15, + # 30.5176 us) is 0.2% different and lands one tick short on the + # second example under floor division. See PR discussion for why + # 8192/269 is the better-evidenced choice pending hardware bench. + for us, expected_ticks in ((8920, 292), (4450, 146)): + got = int(us // TICK) + self.assertLessEqual(abs(got - expected_ticks), 1) + old_got = int(us // OLD_TICK) + self.assertGreater(abs(old_got - expected_ticks), 5) + + def test_round_trip(self): + # Learn-then-send is unaffected by which tick is used, as long as + # both directions agree -- this must hold for TICK just as it held + # for the old constant. + pulses = [9000, 4500, 560, 1690, 560, 560] + packet = pulses_to_data(pulses) + decoded = data_to_pulses(packet) + for original, result in zip(pulses, decoded): + self.assertAlmostEqual(result, original, delta=TICK) + + def test_true_microsecond_nec_leader_is_now_correct(self): + # A real NEC leader (9000/4500 us) built from TRUE microseconds + # (e.g. Home Assistant's infrared platform, not a Broadlink round + # trip) must decode back to ~9000/4500, not ~7% short. + packet = pulses_to_data([9000, 4500]) + decoded = data_to_pulses(packet) + self.assertAlmostEqual(decoded[0], 9000, delta=50) + self.assertAlmostEqual(decoded[1], 4500, delta=50) + + def test_old_constant_was_seven_percent_short_on_real_hardware(self): + # The silicon's timebase is fixed regardless of what the software + # assumed, so what the old code actually put on the wire for a + # true-microsecond input is tick_count * TICK, not + # tick_count * OLD_TICK. This reproduces the ~7% figure from the + # issue's hardware bench (8362us/8437us measured vs ~9000/9116us + # true, same ballpark once packet framing rounding is folded in). + buggy_packet = pulses_to_data([9000, 4500], tick=OLD_TICK) + actually_transmitted = data_to_pulses(buggy_packet, tick=TICK) + self.assertLess(actually_transmitted[0], 9000 - 500) + self.assertLess(actually_transmitted[1], 4500 - 250) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 00000000..37b6447d --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,511 @@ +"""Transport layer: framing, encryption, checksums, discovery and auth. + +These tests replace the UDP socket with a fake so the exact bytes that leave +``send_packet`` and ``scan`` can be checked, and so response validation can +be exercised with corrupted frames. +""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest + +import broadlink +from broadlink import device as device_module +from broadlink import exceptions as e +from broadlink.device import Device +from tests.oracle.harness import HOST, MAC, make_response + +INIT_KEY = bytes.fromhex("097628343fe99e23765c1513accf8b02") +INIT_VECT = bytes.fromhex("562e17996d093d28ddb3ba695a2e6f58") + + +class FakeTransport: + """Datagram transport stand-in: records sendto, feeds canned replies.""" + + def __init__(self, protocol, local_addr, remote_addr, broadcast, replies): + self.protocol = protocol + self.local_addr = local_addr or ("0.0.0.0", 0) + self.remote_addr = remote_addr + self.broadcast = broadcast + self.replies = list(replies) + self.sent: list[tuple[bytes, tuple[str, int] | None]] = [] + self.closed = False + + def sendto(self, data, addr=None): + self.sent.append((bytes(data), addr or self.remote_addr)) + # Each send releases the next canned reply, if any, exactly like a + # device answering one request. + if self.replies: + self.protocol.queue.put_nowait(self.replies.pop(0)) + + def get_extra_info(self, name): + if name == "sockname": + return (self.local_addr[0], self.local_addr[1] or 40000) + return None + + def is_closing(self): + return self.closed + + def close(self): + self.closed = True + + +class FakeNet: + """Replacement for broadlink.device._open_endpoint.""" + + def __init__(self): + self.replies: list[tuple[bytes, tuple[str, int]]] = [] + self.endpoints: list[FakeTransport] = [] + + async def __call__(self, local_addr=None, remote_addr=None, broadcast=False): + protocol = device_module._Protocol() + transport = FakeTransport(protocol, local_addr, remote_addr, broadcast, self.replies) + self.replies = [] + protocol.connection_made(transport) + self.endpoints.append(transport) + return transport, protocol + + +@pytest.fixture +def net(monkeypatch): + fake = FakeNet() + monkeypatch.setattr(device_module, "_open_endpoint", fake) + monkeypatch.setattr(broadlink, "_open_endpoint", fake) + # Keep the retry loop from waiting on real time. + monkeypatch.setattr(device_module, "DEFAULT_RETRY_INTVL", 0.005) + return fake + + +def run(coro): + return asyncio.run(coro) + + +def fixed_device(cls=Device, devtype=0x2737) -> Device: + dev = cls(HOST, MAC, devtype, name="Bench") + dev.count = 0x8000 + return dev + + +# ------------------------------------------------------------------ send_packet + + +def test_send_packet_wire_bytes(net): + dev = fixed_device() + dev.id = 0x00000001 + payload = bytes([0x01]) + bytes(15) + net.replies = [(make_response(dev, bytes(16)), HOST)] + + resp = run(dev.send_packet(0x6A, payload)) + + ep = net.endpoints[-1] + assert ep.remote_addr == HOST + assert len(ep.sent) == 1 + frame, addr = ep.sent[0] + assert addr == HOST + assert frame[0x00:0x08] == bytes.fromhex("5aa5aa555aa5aa55") + assert frame[0x24:0x26] == (0x2737).to_bytes(2, "little") + assert frame[0x26:0x28] == (0x6A).to_bytes(2, "little") + assert frame[0x28:0x2A] == (0x8001).to_bytes(2, "little") # count advanced + assert frame[0x2A:0x30] == MAC[::-1] + assert frame[0x30:0x34] == (1).to_bytes(4, "little") + assert frame[0x34:0x36] == (sum(payload, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + # Encrypted payload: one AES block, decrypts back to the plaintext. + assert len(frame) == 0x38 + 16 + assert dev.decrypt(frame[0x38:]) == payload + # Frame checksum is computed over the frame with the checksum field zeroed. + body = bytearray(frame) + body[0x20:0x22] = b"\x00\x00" + assert frame[0x20:0x22] == (sum(body, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + assert resp[0x22:0x24] == b"\x00\x00" + assert dev.count == 0x8001 + + +def test_send_packet_pads_payload_to_block(net): + dev = fixed_device() + net.replies = [(make_response(dev, b""), HOST)] + run(dev.send_packet(0x6A, bytes(20))) + frame = net.endpoints[-1].sent[0][0] + assert len(frame) == 0x38 + 32 + assert dev.decrypt(frame[0x38:]) == bytes(32) + + +def test_send_packet_counter_wraps_with_high_bit(net): + dev = fixed_device() + dev.count = 0xFFFF + net.replies = [(make_response(dev, b""), HOST)] + run(dev.send_packet(0x6A, b"")) + assert dev.count == 0x8000 + + +def test_send_packet_reuses_one_endpoint_and_serializes(net): + dev = fixed_device() + + async def go(): + net.replies = [(make_response(dev, b""), HOST)] + await dev.send_packet(0x6A, b"") + ep = net.endpoints[-1] + ep.replies = [(make_response(dev, b""), HOST), (make_response(dev, b""), HOST)] + await asyncio.gather(dev.send_packet(0x6A, b"a"), dev.send_packet(0x6A, b"b")) + return ep + + ep = run(go()) + assert len(net.endpoints) == 1 + assert len(ep.sent) == 3 + # Counters are consecutive: the lock kept the two concurrent calls apart. + counts = [int.from_bytes(f[0x28:0x2A], "little") for f, _ in ep.sent] + assert counts == [0x8001, 0x8002, 0x8003] + + +def test_aclose_then_reopen(net): + dev = fixed_device() + + async def go(): + net.replies = [(make_response(dev, b""), HOST)] + await dev.send_packet(0x6A, b"") + await dev.aclose() + assert net.endpoints[-1].closed + net.replies = [(make_response(dev, b""), HOST)] + async with dev: + await dev.send_packet(0x6A, b"") + + run(go()) + assert len(net.endpoints) == 2 + assert net.endpoints[-1].closed # the context manager closed it + + +def test_send_packet_retries_then_times_out(net): + dev = fixed_device() + dev.timeout = 0.02 + net.replies = [] # never answers + with pytest.raises(e.NetworkTimeoutError) as err: + run(dev.send_packet(0x6A, b"")) + assert err.value.errno == -4000 + assert len(net.endpoints[-1].sent) >= 2 # resent at least once + + +def test_send_packet_rejects_short_response(net): + dev = fixed_device() + net.replies = [(bytes(0x10), HOST)] + with pytest.raises(e.DataValidationError) as err: + run(dev.send_packet(0x6A, b"")) + assert err.value.errno == -4007 + + +def test_send_packet_rejects_bad_checksum(net): + dev = fixed_device() + frame = bytearray(make_response(dev, b"")) + frame[0x20] ^= 0xFF + net.replies = [(bytes(frame), HOST)] + with pytest.raises(e.DataValidationError) as err: + run(dev.send_packet(0x6A, b"")) + assert err.value.errno == -4008 + + +def test_stale_reply_is_drained_before_a_request(net): + dev = fixed_device() + + async def go(): + net.replies = [(make_response(dev, b""), HOST)] + await dev.send_packet(0x6A, b"") + ep = net.endpoints[-1] + # A late packet shows up between requests; it must not be taken as + # the answer to the next one. + stale = bytearray(make_response(dev, b"")) + stale[0x20] ^= 0xFF # corrupt so it would fail validation if used + ep.protocol.queue.put_nowait((bytes(stale), HOST)) + ep.replies = [(make_response(dev, bytes([7]) + bytes(15)), HOST)] + resp = await dev.send_packet(0x6A, b"") + return dev.decrypt(resp[0x38:])[0] + + assert run(go()) == 7 + + +# ------------------------------------------------------------------------- auth + + +def test_auth_uses_initial_key_and_installs_session_key(net): + dev = fixed_device() + dev.id = 99 # stale session; auth must reset it before sending + dev.update_aes(bytes(range(16))) # stale key + + session_id = 0x0000BEEF + session_key = bytes.fromhex("00112233445566778899aabbccddeeff") + # The auth response payload: id at 0:4, key at 4:20, encrypted with the + # INITIAL key, which is what the device expects auth to be decrypted with. + fresh = fixed_device() + reply = make_response(fresh, session_id.to_bytes(4, "little") + session_key) + net.replies = [(reply, HOST)] + + assert run(dev.auth()) is True + + frame = net.endpoints[-1].sent[0][0] + assert frame[0x26:0x28] == (0x65).to_bytes(2, "little") + assert frame[0x30:0x34] == bytes(4) # id reset to 0 for the handshake + plaintext = fresh.decrypt(frame[0x38:]) + assert plaintext[0x04:0x14] == bytes([0x31]) * 16 + assert plaintext[0x1E] == 0x01 + assert plaintext[0x2D] == 0x01 + assert plaintext[0x30:0x36] == b"Test 1" + assert len(plaintext) == 0x50 + + assert dev.id == session_id + # The new key is in use: encrypting with it matches an independent cipher. + probe = fixed_device() + probe.update_aes(session_key) + assert dev.encrypt(bytes(16)) == probe.encrypt(bytes(16)) + + +def test_auth_surfaces_device_error(net): + dev = fixed_device() + net.replies = [(make_response(dev, bytes(20), error=0xFFF9), HOST)] + with pytest.raises(e.AuthorizationError): + run(dev.auth()) + + +def test_expired_session_is_reauthenticated_once(net): + dev = fixed_device() + dev.id = 5 + session_key = bytes.fromhex("00112233445566778899aabbccddeeff") + fresh = fixed_device() + auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key) + + async def go(): + # First request: device says the control key expired (-7). + net.replies = [(make_response(dev, b"", error=0xFFF9), HOST)] + await dev._endpoint() + ep = net.endpoints[-1] + # After the expired reply the library must auth (reply 2, under the + # initial key) and resend (reply 3, under the new session key). + renewed = fixed_device() + renewed.update_aes(session_key) + ep.replies = [ + (auth_reply, HOST), + (make_response(renewed, bytes([9]) + bytes(15)), HOST), + ] + ep.replies.insert(0, (make_response(dev, b"", error=0xFFF9), HOST)) + resp = await dev.send_packet(0x6A, bytes(16)) + return ep, resp + + ep, resp = run(go()) + types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent] + assert types == [0x6A, 0x65, 0x6A] + assert dev.id == 0x42 + assert resp[0x22:0x24] == b"\x00\x00" + assert dev.decrypt(resp[0x38:])[0] == 9 + + +def test_reauth_is_not_attempted_twice(net): + dev = fixed_device() + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + expired = (make_response(dev, b"", error=0xFFF9), HOST) + ep.replies = [expired, expired] # request fails, auth fails + return await dev.send_packet(0x6A, b"") + + with pytest.raises(e.AuthorizationError): + run(go()) + + +# ------------------------------------------------------------------- discovery + + +def hello_response(devtype: int, mac: bytes, name: str, locked: bool) -> bytes: + frame = bytearray(0x80) + frame[0x34:0x36] = devtype.to_bytes(2, "little") + frame[0x3A:0x40] = mac[::-1] + frame[0x40 : 0x40 + len(name)] = name.encode() + frame[0x7F] = int(locked) + return bytes(frame) + + +async def collect(aiter): + return [x async for x in aiter] + + +def test_scan_builds_hello_packet_and_parses_replies(net): + other = bytes.fromhex("34ea34000001") + net.replies = [ + (hello_response(0x6026, MAC, "Bedroom RM", False), ("192.0.2.10", 80)), + (hello_response(0x6026, MAC, "Bedroom RM", False), ("192.0.2.10", 80)), # dup + (hello_response(0x2711, other, "Plug", True), ("192.0.2.11", 80)), + ] + + async def go(): + found = [] + async for entry in device_module.scan(timeout=0.02, local_ip_address="192.0.2.2"): + found.append(entry) + ep = net.endpoints[-1] + # replies are released one per send; pull the rest through + while ep.replies: + ep.protocol.queue.put_nowait(ep.replies.pop(0)) + return found + + found = run(go()) + assert found == [ + (0x6026, ("192.0.2.10", 80), MAC, "Bedroom RM", False), + (0x2711, ("192.0.2.11", 80), other, "Plug", True), + ] + ep = net.endpoints[-1] + assert ep.local_addr == ("192.0.2.2", 0) + assert ep.broadcast is True + packet, addr = ep.sent[0] + assert addr == ("255.255.255.255", 80) + assert len(packet) == 0x30 + assert packet[0x26] == 6 + assert packet[0x18:0x1C] == socket.inet_aton("192.0.2.2")[::-1] + assert packet[0x1C:0x1E] == (40000).to_bytes(2, "little") # bound port + body = bytearray(packet) + body[0x20:0x22] = b"\x00\x00" + assert packet[0x20:0x22] == (sum(body, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + assert ep.closed + + +def test_discover_and_hello_build_devices(net): + net.replies = [ + (hello_response(0x6026, MAC, "Bedroom RM", False), ("192.0.2.10", 80)), + ] + devices = run(broadlink.discover(timeout=0.02)) + assert len(devices) == 1 + dev = devices[0] + assert isinstance(dev, broadlink.rm4pro) + assert dev.host == ("192.0.2.10", 80) + assert dev.mac == MAC + assert dev.name == "Bedroom RM" + assert dev.model == "RM4 pro" + assert dev.manufacturer == "Broadlink" + + net.replies = [ + (hello_response(0x6026, MAC, "Bedroom RM", True), ("192.0.2.10", 80)), + ] + dev = run(broadlink.hello("192.0.2.10", timeout=0.02)) + assert dev.is_locked is True + assert net.endpoints[-1].sent[0][1] == ("192.0.2.10", 80) + assert net.endpoints[-1].closed + + +def test_hello_times_out(net): + net.replies = [] + with pytest.raises(e.NetworkTimeoutError): + run(broadlink.hello("192.0.2.10", timeout=0.02)) + + +def test_device_hello_validates_identity(net): + dev = fixed_device(broadlink.rm4pro, 0x6026) + net.replies = [(hello_response(0x6026, MAC, "Renamed", True), HOST)] + assert run(dev.hello()) is True + assert dev.name == "Renamed" + assert dev.is_locked is True + + net.replies = [ + (hello_response(0x6026, bytes.fromhex("000000000001"), "Other", False), HOST) + ] + with pytest.raises(e.DataValidationError): + run(dev.hello()) + + net.replies = [(hello_response(0x2711, MAC, "Other", False), HOST)] + with pytest.raises(e.DataValidationError): + run(dev.hello()) + + +def test_ping_packet(net): + dev = fixed_device() + run(dev.ping()) + packet, addr = net.endpoints[-1].sent[0] + assert addr == HOST + assert len(packet) == 0x30 + assert packet[0x26] == 1 + assert net.endpoints[-1].closed + + +# ------------------------------------------------------------------ gendevice + + +@pytest.mark.parametrize( + ("devtype", "cls", "model"), + [ + (0x2737, broadlink.rmmini, "RM mini 3"), + (0x272A, broadlink.rmpro, "RM pro"), + (0x5F36, broadlink.rmminib, "RM mini 3"), + (0x51DA, broadlink.rm4mini, "RM4 mini"), + (0x6026, broadlink.rm4pro, "RM4 pro"), + (0x2711, broadlink.sp2s, "SP2"), + (0x2720, broadlink.sp2, "SP mini"), + (0x2714, broadlink.a1, "A1"), + (0x4EAD, broadlink.hysen, "HY02/HY03"), + (0x60C7, broadlink.lb1, "LB1"), + (0x4EB5, broadlink.mp1, "MP1-1K4S"), + ], +) +def test_gendevice_known_ids(devtype, cls, model): + dev = broadlink.gendevice(devtype, HOST, MAC) + assert type(dev) is cls + assert dev.model == model + assert dev.type == cls.TYPE + + +def test_gendevice_unknown_id_is_generic_device(): + dev = broadlink.gendevice(0xFFFF, HOST, "a043b05510f7") + assert type(dev) is Device + assert dev.type == "Unknown" + assert dev.mac == MAC + + +def test_product_table_has_no_duplicate_ids(): + seen = {} + for cls, products in broadlink.SUPPORTED_TYPES.items(): + for pid in products: + assert pid not in seen, f"{pid:#06x} in both {seen[pid]} and {cls}" + seen[pid] = cls.__name__ + + +# ----------------------------------------------------------------------- setup + + +def test_setup_packet(net): + run(broadlink.setup("MyWifi", "hunter2", 3, ip_address="192.0.2.255")) + packet, addr = net.endpoints[-1].sent[0] + assert addr == ("192.0.2.255", 80) + assert net.endpoints[-1].broadcast is True + assert len(packet) == 0x88 + assert packet[0x26] == 0x14 + assert packet[68:74] == b"MyWifi" + assert packet[100:107] == b"hunter2" + assert packet[0x84] == 6 + assert packet[0x85] == 7 + assert packet[0x86] == 3 + body = bytearray(packet) + body[0x20:0x22] = b"\x00\x00" + assert packet[0x20:0x22] == (sum(body, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + assert net.endpoints[-1].closed + + +# ------------------------------------------------------------------ exceptions + + +@pytest.mark.parametrize( + ("code", "exc"), + [ + (0xFFFF, e.AuthenticationError), + (0xFFF9, e.AuthorizationError), + (0xFFFB, e.StorageError), + (0xFFFE, e.ConnectionClosedError), + ], +) +def test_check_error_maps_codes(code, exc): + with pytest.raises(exc): + e.check_error(code.to_bytes(2, "little")) + + +def test_check_error_passes_zero(): + e.check_error(b"\x00\x00") + + +def test_check_error_unknown_code(): + with pytest.raises(e.UnknownError) as err: + e.check_error((0x1234).to_bytes(2, "little")) + assert err.value.errno == 0x1234