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..2a14f56e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +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: Format check + run: ruff format --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..2413a7a3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,245 @@ +# Changelog + +All notable changes to this project are recorded here. The format follows +Keep a Changelog; versions follow Semantic Versioning. + +## 1.0.3 - 2026-09-06 + +Fixes from a third review, this one of 1.0.2. No change to the wire +format. One small API change: `pulses` on a captured signal is a tuple. + +### Fixed + +- When the device answered that the session key had expired and the + re-authentication then failed (for example because the device had been + locked in the app), the call raised `AuthenticationError` from the + re-authentication instead of the error the device gave the request. The + original library never re-authenticated, so a program written against + it, Home Assistant's integration included, handles the request's own + error and never expected the other one. The failed re-authentication is + now logged and the request's original reply is returned, so the caller + sees the same `AuthorizationError` or `ConnectionClosedError` it always + did. +- The authentication generation was read before the request lock was + taken rather than under it, so a request queued behind an `auth()` + could observe a stale generation and skip a re-authentication it needed. +- `aclose()` racing an endpoint that was still being opened could leave + the new socket open and unreferenced. The open now notices the close + and fails with `EndpointClosedError`. +- After a new capture window gives the finalizer its turn, it re-checks + that no other window claimed the device in the meantime. +- `CapturedSignal` and `ParsedPacket` are frozen dataclasses, but they + held a list, so they could not be hashed or put in a set. `pulses` is + now a `tuple[int, ...]`. +- `check_error` unpacks the error code as little-endian explicitly + (`" **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 is asynchronous and adds `capture()`; +> 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. That +is the main change from the original library's API: method names and +arguments are the same, and so are return values, with the small +exceptions listed in `CHANGELOG.md` (the IR tick constant, `pulses_to_data` +returning `bytes`, the unused `Device.lock` attribute removed, `timeout` +parameters typed as floats, and the `mac` in a hello response typed as +`bytes`). + +```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()) -- **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 + +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, 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 +- **Environment sensors**: A1, A2 - **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 +- **Thermostats**: Hysen HY02/HY03 - **Hubs**: S3 +## Timing + +The original library converted microseconds to the device's timing units +with the constant 32.84, which is the right ratio applied the wrong way +round, and it shortened every IR code built from microsecond timings by +about 7 percent. Codes learned from a remote and replayed through the same +device were never affected, which is why it went unnoticed for years. +Version 1.0 uses 8192/269 (about 30.45 us per unit), the value implied by +`protocol.md`, and rounds to the nearest unit instead of truncating. + +Measured on an RM4 Pro against an independent receiver, the same NEC frame +packed with the old constant arrived 5.4 percent short of its intended +length; packed with the corrected constant it arrived 0.6 percent short, +twice, thirteen hours apart, within 22 us of itself. Packets learned by +the device and replayed by name are unchanged. Anything that stores +microsecond timings produced by the old `data_to_pulses` (which reported +them about 7.8 percent long) and re-encodes them with the new +`pulses_to_data` will lengthen by that amount; store the device packet +instead, as `CapturedSignal.packet` does. + ## Installation +Python 3.13 or newer. That is a support decision rather than a technical +one: the code runs on 3.11, but the versions tested in CI are 3.13 and +3.14 and those are the ones Home Assistant ships. + Use pip3 to install the latest version of this module. ``` -pip3 install broadlink +pip3 install python-broadlink ``` +Both this distribution and the original `broadlink` install a package named +`broadlink`, so only one can be present in an environment at a time. Pip +does not warn about this: installing one on top of the other appears to +succeed, and whichever was installed last is the one that `import broadlink` +finds. If both were installed, uninstall both (`pip3 uninstall broadlink +python-broadlink`) and reinstall this one, since `pip3 uninstall broadlink` +alone removes the shared files and leaves `python-broadlink` registered but +unimportable. This matters most where another package pins `broadlink`: +installing it into the same environment silently replaces this async +library with the original synchronous one. + ## 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 +124,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 +133,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 +141,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,31 +149,63 @@ 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 session key expires on the device after a while. When a request comes +back with an expired-key answer, the library authenticates again and +repeats the request once, so a long-running program does not need to +handle that itself. If the second authentication fails, for example +because the device was locked in the app in the meantime, the call raises +the error the device gave the first time, the same `AuthorizationError` +or `ConnectionClosedError` the original library raised, and it is up to +the caller to decide what to do. In the worst case one call can wait out +three timeouts (the request, the authentication, and the repeat), each +bounded by `device.timeout`. + +### Closing + +Each device keeps one UDP socket open for its lifetime (the original +library opened a new one for every call). Close it when you are done with +the device, either with the context manager or explicitly: + +```python3 +async with device: + await device.auth() + print(await device.check_sensors()) + +# or +await device.aclose() ``` +The socket reopens by itself on the next call, so closing is cheap and +safe to do at any time. A request that is in flight when `aclose()` runs +fails with `EndpointClosedError`. An integration that creates devices +should close them when it unloads; a device that is never closed holds +its socket until it is garbage collected. + The next steps depend on the type of device you want to control. ## Universal remotes @@ -105,12 +216,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,23 +230,23 @@ 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: ```python3 -ok = device.check_frequency() +ok, frequency = await device.check_frequency() if ok: - print('Frequency found!') + print(f"Frequency found: {frequency} MHz") ``` 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,88 +257,131 @@ 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: opening a second one raises `CaptureInProgressError` +while the first is still held. Always close a window you leave early +(`aclosing` above does it), otherwise it stays open until Python collects +the generator. + +`CapturedSignal` carries the device's own `packet` bytes (ready for +`send_data`), the decoded `pulses` in microseconds at the corrected 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 ```python3 -state = device.check_power() +state = await device.check_power() ``` ### Checking energy consumption ```python3 -state = device.get_energy() +state = await device.get_energy() ``` ## Power strips ### 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 ```python3 -state = device.check_power() +state = await device.check_power() ``` ## Light bulbs ### Fetching data ```python3 -state = device.get_state() +state = await 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 +389,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..f68df1d6 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 + +import contextlib +from collections.abc import AsyncIterator 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, ping, scan, send_setup_packet 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"), @@ -212,8 +223,8 @@ def gendevice( dev_type: int, - host: Tuple[str, int], - mac: Union[bytes, str], + host: tuple[str, int], + mac: bytes | str, name: str = "", is_locked: bool = False, ) -> Device: @@ -238,64 +249,65 @@ 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, - ) + async with contextlib.aclosing( + 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 + ) as devices: + async for device in devices: + return device + raise e.NetworkTimeoutError( + -4000, + "Network timeout", + f"No response received within {timeout}s", + ) -def discover( - timeout: int = DEFAULT_TIMEOUT, - local_ip_address: Optional[str] = None, +async def discover( + timeout: float = DEFAULT_TIMEOUT, + local_ip_address: str | None = None, discover_ip_address: str = DEFAULT_BCAST_ADDR, discover_ip_port: int = DEFAULT_PORT, -) -> List[Device]: +) -> 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, - local_ip_address: Optional[str] = None, +async def xdiscover( + timeout: float = DEFAULT_TIMEOUT, + local_ip_address: str | None = 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 +338,4 @@ 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() + await send_setup_packet(bytes(payload), ip_address) diff --git a/broadlink/alarm.py b/broadlink/alarm.py index a9b5e879..a7d30549 100644 --- a/broadlink/alarm.py +++ b/broadlink/alarm.py @@ -1,4 +1,5 @@ """Support for alarm kits.""" + from . import exceptions as e from .device import Device @@ -14,11 +15,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..6841f87d --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -1,7 +1,8 @@ """Support for climate control.""" + import enum import struct -from typing import List, Sequence +from collections.abc import Sequence from . import exceptions as e from .device import Device @@ -21,19 +22,19 @@ 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:]) p_len = int.from_bytes(payload[:0x02], "little") - nom_crc = int.from_bytes(payload[p_len:p_len+2], "little") + nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little") real_crc = CRC16.calculate(payload[0x02:p_len]) if nom_crc != real_crc: @@ -52,22 +53,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 @@ -83,9 +84,7 @@ def get_full_status(self) -> dict: data["dif"] = payload[10] data["svh"] = payload[11] data["svl"] = payload[12] - data["room_temp_adj"] = ( - int.from_bytes(payload[13:15], "big", signed=True) / 10.0 - ) + data["room_temp_adj"] = int.from_bytes(payload[13:15], "big", signed=True) / 10.0 data["fre"] = payload[15] data["poweron"] = payload[16] data["unknown"] = payload[17] @@ -127,12 +126,10 @@ 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( - self, auto_mode: int, loop_mode: int, sensor: int = 0 - ) -> None: + 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 +142,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 +155,7 @@ def set_advanced( poweron: int, ) -> None: """Set advanced options.""" - self.send_request( + await self.send_request( [ 0x01, 0x10, @@ -182,47 +179,35 @@ 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( - [ - 0x01, - 0x10, - 0x00, - 0x08, - 0x00, - 0x02, - 0x04, - hour, - minute, - second, - day - ] + await self.send_request( + [0x01, 0x10, 0x00, 0x08, 0x00, 0x02, 0x04, hour, minute, second, day] ) # Set timer schedule @@ -231,7 +216,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 +238,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): @@ -317,9 +302,7 @@ def _encode(self, data: bytes) -> bytes: """Encode data for transport.""" packet = bytearray(10) p_len = 10 + len(data) - struct.pack_into( - " bytes: # payload[0x2:0x8] == bytes([0xbb, 0x00, 0x07, 0x00, 0x00, 0x00]) payload = self.decrypt(response[0x38:]) p_len = int.from_bytes(payload[:0x02], "little") - nom_crc = int.from_bytes(payload[p_len:p_len+2], "little") + nom_crc = int.from_bytes(payload[p_len : p_len + 2], "little") real_crc = CRC16.calculate(payload[0x02:p_len], polynomial=0x9BE4) if nom_crc != real_crc: @@ -341,13 +324,13 @@ def _decode(self, response: bytes) -> bytes: ) d_len = int.from_bytes(payload[0x08:0x0A], "little") - return payload[0x0A:0x0A+d_len] + 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 +352,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 +397,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 +419,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 +430,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 +438,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/const.py b/broadlink/const.py index 19c37f52..0ebf6cf4 100644 --- a/broadlink/const.py +++ b/broadlink/const.py @@ -1,4 +1,5 @@ """Constants.""" + DEFAULT_BCAST_ADDR = "255.255.255.255" DEFAULT_PORT = 80 DEFAULT_RETRY_INTVL = 1 diff --git a/broadlink/cover.py b/broadlink/cover.py index 75317943..5c38bd04 100644 --- a/broadlink/cover.py +++ b/broadlink/cover.py @@ -1,6 +1,7 @@ """Support for covers.""" -import time -from typing import Sequence + +import asyncio +from collections.abc import Sequence from . import exceptions as e from .device import Device @@ -11,7 +12,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 +22,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 +65,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 +90,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 +122,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 +147,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..600356ea 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -1,9 +1,21 @@ -"""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 collections +import contextlib +import logging import random -import time -from typing import Generator, Optional, Tuple, Union +import socket +from collections.abc import AsyncIterator from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -17,77 +29,165 @@ ) from .protocol import Datetime -HelloResponse = Tuple[int, Tuple[str, int], str, str, bool] +_LOGGER = logging.getLogger(__name__) +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. -2: logged out; -7: control key expired; +# -4012: control id error. +_REAUTH_CODES = {-2, -7, -4012} + +# How many recently used request counters to remember. A reply carrying one +# of them (other than the current request's) is a late or duplicate answer +# to an earlier request and is dropped rather than taken as the answer to +# the current one. 64 covers a burst of resends comfortably and ages out +# long before the 16-bit counter wraps. +_RECENT_MAX = 64 + +_CLOSED = (None, None) +"""Sentinel put on the receive queue when the endpoint is closed.""" + + +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: asyncio.DatagramTransport | None = None + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """Keep the transport; the endpoint sends through it.""" + self.transport = transport # type: ignore[assignment] + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Queue every datagram for the request that is waiting.""" + 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: Exception | None) -> None: + """Nothing to do; a waiting request is told through the queue.""" + + def drain(self) -> None: + """Drop anything that arrived before the current request.""" + while not self.queue.empty(): + self.queue.get_nowait() -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 +async def _open_endpoint( + local_addr: tuple[str, int] | None = None, + remote_addr: tuple[str, int] | None = 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: str | None = 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 TimeoutError: break - - devtype = resp[0x34] | resp[0x35] << 8 - mac = resp[0x3A:0x40][::-1] - - if (host, mac, devtype) in discovered: + if len(resp) < 0x80: + continue + entry = _parse_hello(resp, host) + key = (entry[1], entry[2], entry[0]) + if key in discovered: continue - discovered.append((host, mac, devtype)) + discovered.add(key) + yield entry + finally: + transport.close() + - name = resp[0x40:].split(b"\x00")[0].decode() - is_locked = bool(resp[0x7F]) - yield devtype, host, mac, name, is_locked +async def send_setup_packet( + payload: bytes, ip_address: str, port: int = DEFAULT_PORT +) -> None: + """Broadcast one Wi-Fi provisioning packet to a device in AP mode.""" + transport, _ = await _open_endpoint(broadcast=True) + try: + transport.sendto(payload, (ip_address, port)) 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: @@ -100,10 +200,10 @@ class Device: def __init__( self, - host: Tuple[str, int], - mac: Union[bytes, str], + host: tuple[str, int], + mac: bytes | str, devtype: int, - timeout: int = DEFAULT_TIMEOUT, + timeout: float = DEFAULT_TIMEOUT, name: str = "", model: str = "", manufacturer: str = "", @@ -122,37 +222,43 @@ 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 = asyncio.Lock() + self._transport: asyncio.DatagramTransport | None = None + self._protocol: _Protocol | None = None + self._endpoint_addr: tuple[str, int] | None = None + self._recent: collections.deque[int] = collections.deque(maxlen=_RECENT_MAX) + self._reauth_lock = asyncio.Lock() + self._closes = 0 # Bumped by aclose(); guards an open racing a close. + self._auth_generation = 0 + def __repr__(self) -> str: """Return a formal representation of the device.""" return ( - "%s.%s(%s, mac=%r, devtype=%r, timeout=%r, name=%r, " - "model=%r, manufacturer=%r, is_locked=%r)" - ) % ( - self.__class__.__module__, - self.__class__.__qualname__, - self.host, - self.mac, - self.devtype, - self.timeout, - self.name, - self.model, - self.manufacturer, - self.is_locked, + f"{self.__class__.__module__}.{self.__class__.__qualname__}(" + f"{self.host}, mac={self.mac!r}, devtype={self.devtype!r}, " + f"timeout={self.timeout!r}, name={self.name!r}, " + f"model={self.model!r}, manufacturer={self.manufacturer!r}, " + f"is_locked={self.is_locked!r})" ) def __str__(self) -> str: """Return a readable representation of the device.""" - return "%s (%s / %s:%s / %s)" % ( - self.name or "Unknown", - " ".join(filter(None, [self.manufacturer, self.model, hex(self.devtype)])), - *self.host, - ":".join(format(x, "02X") for x in self.mac), - ) + ident = " ".join(filter(None, [self.manufacturer, self.model, hex(self.devtype)])) + mac = ":".join(format(x, "02X") for x in self.mac) + name = self.name or "Unknown" + return f"{name} ({ident} / {self.host[0]}:{self.host[1]} / {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.""" @@ -170,45 +276,56 @@ def decrypt(self, payload: bytes) -> bytes: decryptor = self.aes.decryptor() return decryptor.update(bytes(payload)) + decryptor.finalize() - def auth(self) -> bool: - """Authenticate to the device.""" - self.id = 0 - self.update_aes(bytes.fromhex(self.__INIT_KEY)) + # ---------------------------------------------------------- session + + async def auth(self) -> bool: + """Authenticate to the device. + The session reset, the exchange and the install of the new key all + happen while holding the request lock, so a request queued behind + the lock is never framed with the initial key or device id 0. + """ packet = bytearray(0x50) packet[0x04:0x14] = [0x31] * 16 packet[0x1E] = 0x01 packet[0x2D] = 0x01 - packet[0x30:0x36] = "Test 1".encode() - - response = self.send_packet(0x65, packet) - e.check_error(response[0x22:0x24]) - payload = self.decrypt(response[0x38:]) - - self.id = int.from_bytes(payload[:0x4], "little") - self.update_aes(payload[0x04:0x14]) + packet[0x30:0x36] = b"Test 1" + + async with self._lock: + self.id = 0 + self.update_aes(bytes.fromhex(self.__INIT_KEY)) + response = await self._exchange(self._frame(0x65, bytes(packet))) + e.check_error(response[0x22:0x24]) + payload = self.decrypt(response[0x38:]) + self.id = int.from_bytes(payload[:0x4], "little") + self.update_aes(payload[0x04:0x14]) + self._auth_generation += 1 + _LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id) return True - def hello(self, local_ip_address=None) -> bool: + async def hello(self, local_ip_address: str | None = None) -> bool: """Send a hello message to the device. Device information is checked before updating name and lock status. """ - responses = scan( - timeout=self.timeout, - local_ip_address=local_ip_address, - 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 with contextlib.aclosing( + scan( + timeout=self.timeout, + local_ip_address=local_ip_address, + discover_ip_address=self.host[0], + discover_ip_port=self.host[1], + ) + ) as responses: + async for entry in responses: # noqa: B007 - first reply only + 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 +347,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 +388,46 @@ 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. + + A request in flight fails at once with ``ConnectionClosedError`` + rather than waiting out its timeout. + """ + self._closes += 1 + transport, protocol = self._transport, self._protocol + self._transport = None + self._protocol = None + self._endpoint_addr = None + if transport is not None: + transport.close() + _LOGGER.debug("%s: endpoint closed", self.host[0]) + if protocol is not None: + protocol.queue.put_nowait(_CLOSED) # type: ignore[arg-type] + + async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]: + if self._transport is not None and self._endpoint_addr != self.host: + # The caller changed host; the connected socket points at the + # old address, so drop it. + await self.aclose() + if self._transport is None or self._transport.is_closing(): + closes = self._closes + transport, protocol = await _open_endpoint(remote_addr=self.host) + if self._closes != closes: + # aclose() ran while the socket was being opened. + transport.close() + raise e.EndpointClosedError( + -4013, "Endpoint closed", "The device endpoint was closed" + ) + self._transport, self._protocol = transport, protocol + self._endpoint_addr = self.host + _LOGGER.debug("%s: endpoint opened", self.host[0]) + 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 +446,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 +466,94 @@ 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 its reply, resending on silence. + Replies carry the request's packet counter (offset 0x28), so a reply + is matched to the request by counter. A reply whose counter belongs + to any other recent request (a late answer, or the second answer to + a request that was resent) is dropped; one with a counter this + device has not used recently is accepted, for firmware that may not + echo it. + """ + transport, protocol = await self._endpoint() + protocol.drain() + loop = asyncio.get_running_loop() + start = loop.time() + timeout = self.timeout + count = int.from_bytes(packet[0x28:0x2A], "little") + self._recent.append(count) + sends = 0 + + while True: + transport.sendto(packet) + sends += 1 + if sends > 1: + _LOGGER.debug("%s: no reply, resending (%d)", self.host[0], sends) + resend_at = loop.time() + DEFAULT_RETRY_INTVL + while True: + now = loop.time() + if now - start >= timeout: + break + wait = min(resend_at, start + timeout) - now + try: + resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0)) + except TimeoutError: + if loop.time() - start >= timeout: + break + if loop.time() >= resend_at: + break # Resend. + continue + if resp is None: + raise e.EndpointClosedError( + -4013, "Endpoint closed", "The device endpoint was closed" + ) + resp = self._validate(resp) + reply_count = int.from_bytes(resp[0x28:0x2A], "little") + if reply_count == count or reply_count not in self._recent: + return resp + _LOGGER.debug( + "%s: dropped a reply for an earlier request (counter 0x%04x)", + self.host[0], + reply_count, + ) + if loop.time() - start >= timeout: + _LOGGER.debug("%s: no reply within %ss", self.host[0], timeout) + raise e.NetworkTimeoutError( + -4000, + "Network timeout", + f"No response received within {timeout}s", + ) from None + + async def send_packet(self, packet_type: int, payload: bytes | bytearray) -> 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. + Concurrent callers that hit the same expired key share one + re-authentication and each retry once. If that re-authentication + fails (for example the device has been locked in the app), the + original reply is returned unchanged, so the caller sees the same + error the original library raised and can run its own recovery. + """ + async with self._lock: + generation = self._auth_generation + resp = await self._exchange(self._frame(packet_type, bytes(payload))) + + code = int.from_bytes(resp[0x22:0x24], "little", signed=True) + if code in _REAUTH_CODES: + _LOGGER.debug("%s: device answered %d, re-authenticating", self.host[0], code) + async with self._reauth_lock: + if self._auth_generation == generation: + try: + await self.auth() + except e.BroadlinkException as err: + _LOGGER.debug( + "%s: re-authentication failed: %s", self.host[0], err + ) + return resp + async with self._lock: + resp = await self._exchange(self._frame(packet_type, bytes(payload))) return resp diff --git a/broadlink/exceptions.py b/broadlink/exceptions.py index 2343ad6e..7437d6e8 100644 --- a/broadlink/exceptions.py +++ b/broadlink/exceptions.py @@ -1,4 +1,5 @@ """Exceptions for Broadlink devices.""" + import collections import struct @@ -22,7 +23,7 @@ def __init__(self, *args, **kwargs): def __str__(self): """Return str(self).""" if self.errno is not None: - return "[Errno %s] %s" % (self.errno, self.strerror) + return f"[Errno {self.errno}] {self.strerror}" return self.strerror def __eq__(self, other): @@ -42,13 +43,13 @@ def __init__(self, *args, **kwargs): """Initialize the exception.""" errors = args[0][:] if args else [] counter = collections.Counter(errors) - strerror = "Multiple errors occurred: %s" % counter + strerror = f"Multiple errors occurred: {counter}" super().__init__(strerror, **kwargs) self.errors = errors def __repr__(self): """Return repr(self).""" - return "MultipleErrors(%r)" % self.errors + return f"MultipleErrors({self.errors!r})" def __str__(self): """Return str(self).""" @@ -71,6 +72,16 @@ class ConnectionClosedError(BroadlinkException): """Connection closed error.""" +class EndpointClosedError(ConnectionClosedError): + """The library's own endpoint was closed while a request was in flight. + + Raised locally by ``Device.aclose()``, not by the device. It is a + subclass of ``ConnectionClosedError`` so existing handlers still catch + it, and a distinct class so a caller that closed the device on purpose + can tell it apart from the device's "logged out" (-2) answer. + """ + + class StructureAbnormalError(BroadlinkException): """Structure abnormal error.""" @@ -95,6 +106,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.""" @@ -133,6 +153,7 @@ class UnknownError(BroadlinkException): -4010: (DataValidationError, "Received encrypted data packet length error"), -4011: (DataValidationError, "Received encrypted data packet check error"), -4012: (AuthorizationError, "Device control ID error"), + -4013: (EndpointClosedError, "Endpoint closed"), } @@ -147,6 +168,6 @@ def exception(err_code: int) -> BroadlinkException: def check_error(error: bytes) -> None: """Raise exception if an error occurred.""" - error_code = struct.unpack("h", error)[0] + error_code = struct.unpack(" List[int]: + def get_table(cls, polynomial: int) -> list[int]: """Return the CRC-16 table for a polynomial.""" try: crc_table = cls._cache[polynomial] diff --git a/broadlink/hub.py b/broadlink/hub.py index 0fd4ae53..6dd4ed40 100644 --- a/broadlink/hub.py +++ b/broadlink/hub.py @@ -1,7 +1,7 @@ """Support for hubs.""" -import struct + import json -from typing import Optional +import struct from . import exceptions as e from .device import Device @@ -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,23 +43,23 @@ 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: str | None = 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, - pwr2: Optional[bool] = None, - pwr3: Optional[bool] = None, + did: str | None = None, + pwr1: bool | None = None, + pwr2: bool | None = None, + pwr3: bool | None = None, ) -> dict: """Set the power state of the device.""" state = {} @@ -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) @@ -82,9 +82,7 @@ def _encode(self, flag: int, state: dict) -> bytes: # flag: 1 for reading, 2 for writing. packet = bytearray(12) data = json.dumps(state, separators=(",", ":")).encode() - struct.pack_into( - " dict: """Decode a JSON packet.""" payload = self.decrypt(response[0x38:]) js_len = struct.unpack_from(" 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, - blue: Optional[int] = None, - green: Optional[int] = None, - brightness: Optional[int] = None, - colortemp: Optional[int] = None, - hue: Optional[int] = None, - saturation: Optional[int] = None, - transitionduration: Optional[int] = None, - maxworktime: Optional[int] = None, - bulb_colormode: Optional[int] = None, - bulb_scenes: Optional[str] = None, - bulb_scene: Optional[str] = None, - bulb_sceneidx: Optional[int] = None, + pwr: bool | None = None, + red: int | None = None, + blue: int | None = None, + green: int | None = None, + brightness: int | None = None, + colortemp: int | None = None, + hue: int | None = None, + saturation: int | None = None, + transitionduration: int | None = None, + maxworktime: int | None = None, + bulb_colormode: int | None = None, + bulb_scenes: str | None = None, + bulb_scene: str | None = None, + bulb_sceneidx: int | None = None, ) -> dict: """Set the power state of the device.""" state = {} @@ -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) @@ -102,7 +102,7 @@ def _decode(self, response: bytes) -> dict: """Decode a JSON packet.""" payload = self.decrypt(response[0x38:]) js_len = struct.unpack_from(" 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, - blue: Optional[int] = None, - green: Optional[int] = None, - brightness: Optional[int] = None, - colortemp: Optional[int] = None, - hue: Optional[int] = None, - saturation: Optional[int] = None, - transitionduration: Optional[int] = None, - maxworktime: Optional[int] = None, - bulb_colormode: Optional[int] = None, - bulb_scenes: Optional[str] = None, - bulb_scene: Optional[str] = None, + pwr: bool | None = None, + red: int | None = None, + blue: int | None = None, + green: int | None = None, + brightness: int | None = None, + colortemp: int | None = None, + hue: int | None = None, + saturation: int | None = None, + transitionduration: int | None = None, + maxworktime: int | None = None, + bulb_colormode: int | None = None, + bulb_scenes: str | None = None, + bulb_scene: str | None = None, ) -> dict: """Set the power state of the device.""" state = {} @@ -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) @@ -184,9 +184,7 @@ def _encode(self, flag: int, state: dict) -> bytes: # flag: 1 for reading, 2 for writing. packet = bytearray(12) data = json.dumps(state, separators=(",", ":")).encode() - struct.pack_into( - " dict: """Decode a JSON packet.""" payload = self.decrypt(response[0x38:]) js_len = struct.unpack_from(" bytes: - """Convert a microsecond duration sequence into a Broadlink IR packet.""" +_LOGGER = logging.getLogger(__name__) + +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: + """True for the radio bands.""" + return self is not SignalKind.IR + + @classmethod + def classify(cls, type_byte: int) -> Self: + """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 +105,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 +130,296 @@ 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: tuple[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], tuple(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: tuple[int, ...] = field(repr=False) + repeat: int = 0 + frequency_mhz: float | None = None + type_byte: int | None = None + captured_at: float = field(default_factory=time.time, repr=False) + + @classmethod + def from_packet( + cls, + packet: bytes, + frequency_mhz: float | None = None, + *, + kind: SignalKind | None = None, + ) -> Self: + """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, + tuple(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 + # Weak reference to the async generator of the current capture + # window, if any. See _claim_window. + self._window: weakref.ReferenceType | None = None + + @property + def capture_active(self) -> bool: + """True while a capture window is open on this device.""" + window = self._window() if self._window is not None else None + return window is not None and window.ag_frame is not None + + def _check_window(self) -> None: + """Fail fast at call time if another window is being iterated now.""" + old = self._window() if self._window is not None else None + if old is not None and old.ag_frame is not None and old.ag_running: + raise e.CaptureInProgressError("A capture window is already open") + + async def _claim_window(self, new: weakref.ReferenceType) -> None: + """Make sure the previous window is really gone, then register ``new``. + + A consumer that walked away from a window without closing it (for + example ``break`` out of ``async for`` with no ``aclosing``) leaves + the generator to asyncio's finalizer, which closes it on the next + loop iteration once nothing references it. Give that a turn. If the + window is still alive after that, someone still holds it, whether + they are inside ``__anext__`` or paused between signals, and the new + window is refused rather than taken from under them. + """ + prev = self._window + if prev is not None: + old = prev() + if old is not None and old.ag_frame is not None: + if old.ag_running: + raise e.CaptureInProgressError("A capture window is already open") + del old # Hold no reference while the finalizer gets its turn. + await asyncio.sleep(0) + await asyncio.sleep(0) + old = prev() + if old is not None and old.ag_frame is not None: + raise e.CaptureInProgressError( + "A capture window is already open; close it with aclose() first" + ) + if self._window is not prev: + # Another claimant got in during the two turns above. + raise e.CaptureInProgressError("A capture window is already open") + self._window = new + + 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: + opening one while another is still held raises + ``CaptureInProgressError``. A window whose generator was dropped + without being closed is finalized by asyncio on the next loop + iteration and does not block. + """ + self._check_window() + holder: list = [] + gen = self._capture_loop( + self.enter_learning, + window, + stop_after_first, + poll_interval, + rearm_interval, + SignalKind.IR, + None, + claim=holder, + ) + holder.append(weakref.ref(gen)) + return gen + + 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: float | None, + *, + claim: list | None = None, + ) -> AsyncIterator[CapturedSignal]: + # ``claim`` carries a weak reference to this generator (filled in by + # the caller after creating it); None means the caller owns the + # window claim, as capture_rf does for its inner loop. + 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 claim: + await self._claim_window(claim[0]) + + 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 + _LOGGER.debug("%s: capture window armed (%s)", self.host[0], kind.name) + + 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, e.ReadError): + # "Nothing yet": -5 on the RM4 Pro, -10 on some older + # firmware (upstream's CLI and Home Assistant tolerate + # both). + data = b"" + except e.NetworkTimeoutError: + timeouts += 1 + if timeouts >= 3: + raise + generation = -1 # Re-arm; the device's state is unknown. + continue + timeouts = 0 + + if data: + try: + signal = CapturedSignal.from_packet(data, frequency_mhz, kind=kind) + except ValueError as err: + # A packet the device returned but we cannot decode. Log + # it, re-arm and keep the window open. + _LOGGER.warning( + "%s: ignoring an undecodable capture (%s): %s", + self.host[0], + err, + data.hex(), + ) + generation = -1 + else: + _LOGGER.debug( + "%s: captured %d bytes (%s)", self.host[0], len(data), kind.name + ) + yield signal + 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 + _LOGGER.debug("%s: capture window re-armed", self.host[0]) class rmpro(rmmini): @@ -84,37 +427,144 @@ 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: float | None = 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) + + def capture_rf( + self, + window: float = 30.0, + *, + frequency: float | None = 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. Closing the generator + during a sweep sends nothing; the device ends the sweep on its own. + Each ``CapturedSignal`` carries the carrier in ``frequency_mhz``, + which the packet itself does not record. + """ + self._check_window() + holder: list = [] + gen = self._capture_rf_loop( + window, + frequency, + stop_after_first, + poll_interval, + rearm_interval, + claim=holder, + ) + holder.append(weakref.ref(gen)) + return gen + + async def _capture_rf_loop( + self, + window: float, + frequency: float | None, + stop_after_first: bool, + poll_interval: float, + rearm_interval: float, + *, + claim: list, + ) -> AsyncIterator[CapturedSignal]: + if window < 0 or poll_interval <= 0: + raise ValueError("window must be 0 or positive, poll_interval positive") + await self._claim_window(claim[0]) + + loop = asyncio.get_running_loop() + deadline = loop.time() + window if window else None + + if frequency is None: + frequency = await self._sweep(deadline, poll_interval) + 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 + inner = self._capture_loop( + arm, + window, + stop_after_first, + poll_interval, + rearm_interval, + kind, + frequency, + claim=None, + ) + try: + async for signal in inner: + yield signal + finally: + await inner.aclose() + + async def _sweep(self, deadline: float | None, poll_interval: float) -> float | None: + """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,14 +572,14 @@ 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 +621,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..3091707d 100644 --- a/broadlink/sensor.py +++ b/broadlink/sensor.py @@ -1,5 +1,6 @@ """Support for sensors.""" -from typing import Sequence + +from collections.abc import Sequence from . import exceptions as e from .device import Device @@ -16,9 +17,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 +27,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 +48,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 +73,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..b154e3ca 100644 --- a/broadlink/switch.py +++ b/broadlink/switch.py @@ -1,7 +1,7 @@ """Support for switches.""" + import json import struct -from typing import Optional from . import exceptions as e from .device import Device @@ -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,22 +118,22 @@ 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, - indicator: Optional[bool] = None, - ntlbrightness: Optional[int] = None, - maxworktime: Optional[int] = None, - childlock: Optional[bool] = None, + pwr: bool | None = None, + ntlight: bool | None = None, + indicator: bool | None = None, + ntlbrightness: int | None = None, + maxworktime: int | None = None, + childlock: bool | None = None, ) -> dict: """Set state of device.""" state = {} @@ -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: @@ -187,7 +187,7 @@ def _decode(self, response: bytes) -> dict: e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) js_len = struct.unpack_from(" 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"] @@ -235,7 +235,7 @@ def _decode(self, response: bytes) -> dict: e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) js_len = struct.unpack_from(" 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, - pwr2: Optional[bool] = None, - maxworktime: Optional[int] = None, - maxworktime1: Optional[int] = None, - maxworktime2: Optional[int] = None, - idcbrightness: Optional[int] = None, + pwr: bool | None = None, + pwr1: bool | None = None, + pwr2: bool | None = None, + maxworktime: int | None = None, + maxworktime1: int | None = None, + maxworktime2: int | None = None, + idcbrightness: int | None = None, ) -> dict: """Set the power state of the device.""" state = {} @@ -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) @@ -312,7 +312,7 @@ def _decode(self, response: bytes) -> dict: """Decode a message.""" payload = self.decrypt(response[0x38:]) js_len = struct.unpack_from(" dict: """Set the power state of the device.""" state = {} @@ -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,15 +452,15 @@ 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] def get_value(start, end, factors): value = sum( - int(payload_str[i-2:i]) * factor - for i, factor in zip(range(start, end, -2), factors) + int(payload_str[i - 2 : i]) * factor + for i, factor in zip(range(start, end, -2), factors, strict=False) ) return value diff --git a/cli/README.md b/cli/README.md index b7e48dc9..301196af 100644 --- a/cli/README.md +++ b/cli/README.md @@ -6,9 +6,9 @@ This is a command line interface for the python-broadlink API. Requirements ------------ -You need to install the module first: +You need to install the module first (Python 3.13 or newer): ``` -pip3 install broadlink +pip install python-broadlink ``` Installation @@ -67,7 +67,13 @@ broadlink_cli --device @BEDROOM.device --learn #### Learn RF code and show at console ``` -broadlink_cli --device @BEDROOM.device --rfscanlearn +broadlink_cli --device @BEDROOM.device --rflearn +``` +The device sweeps for the remote's carrier while you hold a button, then +learns the code from a short press. The sweep is unreliable on some +firmware; if you know the carrier, skip it: +``` +broadlink_cli --device @BEDROOM.device --rflearn --frequency 433.92 ``` #### Learn IR code and save to file @@ -77,7 +83,14 @@ broadlink_cli --device @BEDROOM.device --learnfile LG-TV.power #### Learn RF code and save to file ``` -broadlink_cli --device @BEDROOM.device --rfscanlearn --learnfile LG-TV.power +broadlink_cli --device @BEDROOM.device --rflearn --learnfile LG-TV.power +``` + +#### Listen for longer, or for several codes +`--window` sets how many seconds to listen (default 30); `--keep` prints +every code heard during the window instead of stopping at the first: +``` +broadlink_cli --device @BEDROOM.device --learn --window 120 --keep ``` #### Send code @@ -90,6 +103,11 @@ broadlink_cli --device @BEDROOM.device --send DATA broadlink_cli --device @BEDROOM.device --send @LG-TV.power ``` +#### Send microsecond durations, repeated +``` +broadlink_cli --device @BEDROOM.device --send --durations --repeat 2 +9000 -4500 +560 -560 +``` + #### Check temperature ``` broadlink_cli --device @BEDROOM.device --temperature diff --git a/cli/broadlink_cli b/cli/broadlink_cli old mode 100755 new mode 100644 index 7913e332..7884f289 --- a/cli/broadlink_cli +++ b/cli/broadlink_cli @@ -1,13 +1,15 @@ #!/usr/bin/env python3 import argparse +import asyncio import base64 +import sys import time +from contextlib import AsyncExitStack, aclosing from typing import List import broadlink from broadlink.const import DEFAULT_PORT -from broadlink.exceptions import ReadError, StorageError -from broadlink.remote import data_to_pulses, pulses_to_data +from broadlink.remote import CapturedSignal, data_to_pulses, pulses_to_data TIMEOUT = 30 @@ -29,6 +31,35 @@ def parse_pulses(data: List[str]) -> List[int]: return [abs(int(s)) for s in data] +def show(signal: CapturedSignal) -> None: + """Print a captured signal in every format and save it if asked.""" + raw_fmt = signal.packet.hex() + base64_fmt = base64.b64encode(signal.packet).decode('ascii') + pulse_fmt = format_pulses(signal.pulses) + + print("Packet found!") + if signal.frequency_mhz: + print("Frequency: {}MHz".format(signal.frequency_mhz)) + 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) + + +async def listen(window) -> int: + """Drain a capture window, printing each signal; return how many.""" + heard = 0 + async with aclosing(window) as signals: + async for signal in signals: + heard += 1 + show(signal) + return heard + + parser = argparse.ArgumentParser(fromfile_prefix_chars='@') parser.add_argument("--device", help="device definition as 'type host mac'") parser.add_argument("--type", type=auto_int, default=0x2712, help="type of device") @@ -50,6 +81,12 @@ parser.add_argument("--learn", action="store_true", help="learn command") parser.add_argument("--rflearn", action="store_true", help="rf scan learning") parser.add_argument("--frequency", type=float, help="specify radiofrequency for learning") parser.add_argument("--learnfile", help="save learned command to a specified file") +parser.add_argument("--window", type=float, default=TIMEOUT, + help="seconds to keep listening while learning (default %(default)s)") +parser.add_argument("--keep", action="store_true", + help="keep listening for the whole window and print every code heard") +parser.add_argument("--repeat", type=int, default=0, + help="with --send --durations: extra transmissions after the first") parser.add_argument("--durations", action="store_true", help="use durations in micro seconds instead of the Broadlink format") parser.add_argument("--convert", action="store_true", help="convert input data to durations") @@ -57,165 +94,135 @@ 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...") - - start = time.time() - while time.time() - start < TIMEOUT: - time.sleep(1) - locked, frequency = dev.check_frequency() - if locked: - break +async def main(): + async with AsyncExitStack() as stack: + await run_commands(stack) + + +async def run_commands(stack: AsyncExitStack): + """Run the requested commands; the device is closed with the stack.""" + 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 stack.enter_async_context(dev) + 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), repeat=args.repeat) + if args.durations + else bytes.fromhex(''.join(args.data)) + ) + await dev.send_data(data) + if args.learn or (args.learnfile and not args.rflearn): + print("Learning...") + heard = await listen(dev.capture(window=args.window, stop_after_first=not args.keep)) + if not heard: + print("No data received...") + sys.exit(1) + if args.check: + if await dev.check_power(): + print('* ON *') else: - print("Radiofrequency not found") - dev.cancel_sweep_frequency() - exit(1) - - print("Radiofrequency detected: {}MHz".format(frequency)) - print("You can now let go of the button") + 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...") - input("Press enter to continue...") + 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() + sys.exit(1) - print("Press the button again, now a short press.") + print("Radiofrequency detected: {}MHz".format(frequency)) + print("You can now let go of the button") - dev.find_rf_packet(frequency) + input("Press enter to continue...") - 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("Press the button again, now a short press.") - print("Packet found!") - raw_fmt = data.hex() - base64_fmt = base64.b64encode(data).decode('ascii') - pulse_fmt = format_pulses(data_to_pulses(data)) + heard = await listen( + dev.capture_rf(window=args.window, frequency=frequency, stop_after_first=not args.keep) + ) + if not heard: + print("No data received...") + sys.exit(1) - 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..af17ec80 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-broadlink" +version = "1.0.3" +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] +select = ["E", "W", "F", "I", "UP", "B", "ASYNC", "RUF"] +ignore = [ + # The device timeout parameters are the protocol timeout, not a + # cancellation scope. + "ASYNC109", + # Inherited from upstream and left alone: mutable class-level tables on + # device classes, and the type comparison in BroadlinkException.__eq__. + "RUF012", + "E721", +] + +[tool.ruff.lint.per-file-ignores] +# The package __init__ re-exports the public API. +"broadlink/__init__.py" = ["F401"] +# Tests fire helper tasks without keeping a reference on purpose. +"tests/*" = ["RUF006"] +# Long example payloads and comments inherited from upstream. +"broadlink/climate.py" = ["E501"] +"broadlink/light.py" = ["E501"] +"broadlink/switch.py" = ["E501"] 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..df265748 --- /dev/null +++ b/tests/oracle/cases.py @@ -0,0 +1,653 @@ +"""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] = (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..8c5c5254 --- /dev/null +++ b/tests/oracle/harness.py @@ -0,0 +1,157 @@ +"""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 ({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 = 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: + 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..222c4ecd --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,727 @@ +"""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) + + +# Every delay below is a multiple of this unit. The windows and presses are +# tens of milliseconds apart, which is plenty on a laptop but tight on a +# loaded CI runner, so the unit is deliberately generous. +UNIT = 0.03 + +FAST = dict(poll_interval=UNIT, 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, 3 * UNIT)) + 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 == tuple(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_active is False + + +def test_capture_window_elapses_with_nothing(): + device, fake = make() + signals = run(_collect(device.capture(window=5 * UNIT, **FAST))) + assert signals == [] + assert fake.count(CMD_LEARN) == 1 + assert fake.count(CMD_CHECK) >= 3 + assert device.capture_active 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, 2 * UNIT)) + loop.create_task(press_later(fake, RF, 6 * UNIT)) + return [ + s + async for s in device.capture( + window=12 * UNIT, 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(2 * UNIT) + results.append(fake.press(IR)) + results.append(fake.press(RF)) # Device not armed: lost. + await asyncio.sleep(3 * UNIT) + results.append(fake.press(RF)) # Re-armed by then. + + loop.create_task(presses()) + signals = [ + s + async for s in device.capture( + window=10 * UNIT, 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(2 * UNIT) + await device.send_data(IR) + fake.expire() # Whatever the send did to the session, assume the worst. + await asyncio.sleep(3 * UNIT) + 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=20 * UNIT, **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(2 * UNIT) + fake.expire() + assert fake.press(IR) is False # Lost: the device is deaf. + await asyncio.sleep(5 * UNIT) # 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=30 * UNIT, poll_interval=1 * UNIT, rearm_interval=4 * UNIT + ) + ] + 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, 2 * UNIT)) + 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_active 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(2 * UNIT) + with pytest.raises(e.CaptureInProgressError): + await _collect(device.capture(window=1, **FAST)) + assert device.capture_active is True + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + run(go()) + assert device.capture_active is False + + +def test_dropped_window_does_not_block_the_next_one(): + """Breaking out of ``async for`` without closing the generator leaves + it to asyncio's finalizer; the next capture() gives that a turn and + proceeds.""" + device, fake = make() + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT)) + got = None + async for s in device.capture(window=1, stop_after_first=False, **FAST): + got = s + break # No reference kept; the generator is collectable. + asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT)) + second = [s async for s in device.capture(window=1, **FAST)] + return got, second + + got, second = run(go()) + assert got.packet == IR + assert [s.packet for s in second] == [RF] + assert device.capture_active is False + + +def test_held_window_is_not_taken_by_the_next_one(): + """A window the consumer still holds is refused to a newcomer, even if + the consumer is not inside the generator at that instant, until the + consumer closes it.""" + device, fake = make() + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT)) + first = device.capture(window=1, stop_after_first=False, **FAST) + async for _ in first: + break # Still referenced by ``first``. + with pytest.raises(e.CaptureInProgressError): + await _collect(device.capture(window=1, **FAST)) + assert device.capture_active is True + # A refused attempt must not have displaced the live window. + with pytest.raises(e.CaptureInProgressError): + await _collect(device.capture_rf(window=1, frequency=433.92, **FAST)) + assert device.capture_active is True + await first.aclose() + assert device.capture_active is False + asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT)) + return [s async for s in device.capture(window=1, **FAST)] + + second = run(go()) + assert [s.packet for s in second] == [RF] + + +def test_paused_consumer_keeps_its_window(): + """The README's own loop awaits between signals. An intruder calling + capture() during that pause must be refused, and the consumer must + keep receiving.""" + device, fake = make() + + async def go(): + loop = asyncio.get_running_loop() + got = [] + intruder = {"error": None, "signals": None} + + async def consumer(): + async with aclosing( + device.capture(window=30 * UNIT, stop_after_first=False, **FAST) + ) as window: + async for s in window: + got.append(s) + await asyncio.sleep(4 * UNIT) # Paused, not running. + + async def intrude(): + await asyncio.sleep(3 * UNIT) # During the consumer's pause. + try: + intruder["signals"] = await _collect(device.capture(window=1, **FAST)) + except e.CaptureInProgressError as err: + intruder["error"] = err + + loop.create_task(press_later(fake, IR, 2 * UNIT)) + loop.create_task(press_later(fake, RF, 12 * UNIT)) + task = loop.create_task(consumer()) + await intrude() + await task + return got, intruder + + got, intruder = run(go()) + assert isinstance(intruder["error"], e.CaptureInProgressError) + assert intruder["signals"] is None + assert [s.packet for s in got] == [IR, RF] + + +def test_unreferenced_unstarted_window_does_not_block(): + device, fake = make() + + async def go(): + device.capture(window=1, **FAST) # created and dropped, never iterated + asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT)) + return [s async for s in device.capture(window=1, **FAST)] + + assert len(run(go())) == 1 + + +def test_undecodable_packet_does_not_end_the_window(): + """A returned packet whose declared length runs into a truncated escape + is logged and skipped; the window re-arms and the next signal lands.""" + device, fake = make() + bad = bytes([0x26, 0x00, 0x03, 0x00, 0x10, 0x00]) # escape with no bytes after + + async def go(): + loop = asyncio.get_running_loop() + loop.create_task(press_later(fake, bad, 2 * UNIT)) + loop.create_task(press_later(fake, IR, 6 * UNIT)) + return [s async for s in device.capture(window=1, **FAST)] + + signals = run(go()) + assert [s.packet for s in signals] == [IR] + assert fake.count(CMD_LEARN) >= 2 # Re-armed after the bad one. + + +def test_older_firmware_read_error_means_nothing_yet(): + device, fake = make() + fake.nothing_yet_code = -10 # ReadError + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, IR, 3 * UNIT)) + return [s async for s in device.capture(window=1, **FAST)] + + signals = run(go()) + assert len(signals) == 1 + assert fake.count(CMD_CHECK) >= 2 + + +def test_capture_rf_abandoned_then_ir_window(): + device, fake = make() + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT)) + async for _ in device.capture_rf( + window=1, frequency=433.92, stop_after_first=False, **FAST + ): + break # Dropped, not held. + asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT)) + return [s async for s in device.capture(window=1, **FAST)] + + signals = run(go()) + assert [s.kind for s in signals] == [SignalKind.IR] + assert device.capture_active 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, 5 * UNIT)) + 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_active 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, 3 * UNIT)) + 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..c0a1accb --- /dev/null +++ b/tests/test_oracle.py @@ -0,0 +1,65 @@ +"""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..72cd02ef --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,64 @@ +"""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, strict=True): + 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..770a45e0 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,857 @@ +"""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) + # 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 + + +def stamped(dev: Device, payload: bytes, count: int, error: int = 0) -> bytes: + """A response frame that echoes a packet counter, as real firmware does.""" + frame = bytearray(make_response(dev, payload, error)) + frame[0x28:0x2A] = count.to_bytes(2, "little") + checksum = sum(frame, 0xBEAF) - sum(frame[0x20:0x22]) & 0xFFFF + frame[0x20:0x22] = checksum.to_bytes(2, "little") + return bytes(frame) + + +def test_late_reply_to_timed_out_request_is_not_taken_as_next_reply(net): + """The defect that 0.19.0 could not have because it threw its socket away + after every call: a slow answer to request 1 arriving after request 1 + timed out must not be returned as the answer to request 2.""" + dev = fixed_device() + dev.timeout = 0.02 + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + with pytest.raises(e.NetworkTimeoutError): + await dev.send_packet(0x6A, b"") # request 1, count 0x8001, no answer + # Its late reply lands while request 2 (count 0x8002) is waiting. + late = stamped(dev, bytes([1]) + bytes(15), 0x8001) + good = stamped(dev, bytes([2]) + bytes(15), 0x8002) + ep.replies = [late, good] and [] + ep.protocol.queue.put_nowait((late, HOST)) + + async def answer_later(): + await asyncio.sleep(0.005) + ep.protocol.queue.put_nowait((good, HOST)) + + asyncio.get_running_loop().create_task(answer_later()) + dev.timeout = 1 + resp = await dev.send_packet(0x6A, b"") + return dev.decrypt(resp[0x38:])[0] + + assert run(go()) == 2 + + +def test_second_answer_to_a_resent_request_is_not_taken_as_next_reply(net): + """The retry path: a request goes unanswered for a resend interval, is + resent with the same counter, and the device answers both copies. The + second answer must not be taken as the reply to the next request.""" + dev = fixed_device() + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + first = stamped(dev, bytes([1]) + bytes(15), 0x8001) + second = stamped(dev, bytes([2]) + bytes(15), 0x8002) + sends = {"n": 0} + + def sendto(data, addr=None): + ep.sent.append((bytes(data), addr or ep.remote_addr)) + sends["n"] += 1 + if sends["n"] == 2: # The resend of request 1 gets answered... + ep.protocol.queue.put_nowait((first, HOST)) + + ep.sendto = sendto + resp1 = await dev.send_packet(0x6A, b"") # count 0x8001, answered on resend + assert dev.decrypt(resp1[0x38:])[0] == 1 + assert sends["n"] == 2 + + # ...and the original copy's answer shows up while request 2 waits, + # followed by request 2's own answer. + async def late_then_real(): + await asyncio.sleep(0.003) + ep.protocol.queue.put_nowait((first, HOST)) # duplicate, counter 0x8001 + await asyncio.sleep(0.003) + ep.protocol.queue.put_nowait((second, HOST)) + + asyncio.get_running_loop().create_task(late_then_real()) + resp2 = await dev.send_packet(0x6A, b"") # count 0x8002 + return dev.decrypt(resp2[0x38:])[0] + + assert run(go()) == 2 + + +def test_auth_never_lets_a_queued_request_out_with_id_zero(net): + """A request queued behind the lock while auth() runs must be framed + after the new session is installed, never with the initial key and + device id 0.""" + 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) + renewed = fixed_device() + renewed.update_aes(session_key) + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + authed = {"done": False} + + loop = asyncio.get_running_loop() + + def sendto(data, addr=None): + ep.sent.append((bytes(data), addr or ep.remote_addr)) + ptype = int.from_bytes(data[0x26:0x28], "little") + if ptype == 0x65: + authed["done"] = True + reply = auth_reply + elif not authed["done"]: + reply = make_response(dev, b"", error=0xFFF9) # -7 expired + else: + reply = make_response(renewed, bytes([7]) + bytes(15)) + # Answer a little later, like a real device, so the caller + # suspends and the second caller queues behind the lock. + loop.call_later(0.002, ep.protocol.queue.put_nowait, (reply, ep.remote_addr)) + + ep.sendto = sendto + a, b = await asyncio.gather( + dev.send_packet(0x6A, b"a"), dev.send_packet(0x6A, b"b") + ) + return ep, a, b + + ep, a, b = run(go()) + ids = [ + (int.from_bytes(f[0x26:0x28], "little"), int.from_bytes(f[0x30:0x34], "little")) + for f, _ in ep.sent + ] + for ptype, dev_id in ids: + if ptype != 0x65: + assert dev_id in (5, 0x42), ids + assert [p for p, _ in ids].count(0x65) == 1 + assert dev.decrypt(a[0x38:])[0] == 7 + assert dev.decrypt(b[0x38:])[0] == 7 + + +def test_reply_with_unknown_counter_is_accepted(net): + """Firmware that does not echo the counter must keep working.""" + dev = fixed_device() + + async def go(): + net.replies = [(stamped(dev, bytes([5]) + bytes(15), 0x0000), HOST)] + resp = await dev.send_packet(0x6A, b"") + return dev.decrypt(resp[0x38:])[0] + + assert run(go()) == 5 + + +def test_aclose_fails_inflight_request_fast(net): + dev = fixed_device() + dev.timeout = 5 + net.replies = [] + + async def go(): + task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b"")) + await asyncio.sleep(0.01) + t0 = asyncio.get_running_loop().time() + await dev.aclose() + with pytest.raises(e.EndpointClosedError) as err: + await task + assert isinstance(err.value, e.ConnectionClosedError) + assert err.value.errno == -4013 + return asyncio.get_running_loop().time() - t0 + + assert run(go()) < 1.0 + + +def test_aclose_during_endpoint_open_does_not_leak(net, monkeypatch): + """aclose() landing while create_datagram_endpoint is still running must + not leave the freshly opened socket behind.""" + dev = fixed_device() + slow = net + + async def slow_open(**kwargs): + await asyncio.sleep(0.02) + return await slow(**kwargs) + + monkeypatch.setattr(device_module, "_open_endpoint", slow_open) + + async def go(): + task = asyncio.get_running_loop().create_task(dev.send_packet(0x6A, b"")) + await asyncio.sleep(0.005) # inside the slow open + await dev.aclose() + with pytest.raises(e.EndpointClosedError): + await task + + run(go()) + assert dev._transport is None + assert all(ep.closed for ep in net.endpoints) + + +def test_host_change_reopens_endpoint(net): + dev = fixed_device() + + async def go(): + net.replies = [(make_response(dev, b""), HOST)] + await dev.send_packet(0x6A, b"") + dev.host = ("192.0.2.99", 80) + net.replies = [(make_response(dev, b""), ("192.0.2.99", 80))] + await dev.send_packet(0x6A, b"") + + run(go()) + assert [ep.remote_addr for ep in net.endpoints] == [HOST, ("192.0.2.99", 80)] + assert net.endpoints[0].closed + + +# ------------------------------------------------------------------------- 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_failed_reauth_returns_the_original_reply(net): + """When the library's own re-authentication fails, the caller must see + the reply the device gave to its request, exactly as 0.19.0 would have + shown it, so the caller's own recovery (Home Assistant's reauth flow) + still runs.""" + 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 -7, auth fails -7 + resp = await dev.send_packet(0x6A, b"") + return ep, resp + + ep, resp = run(go()) + assert int.from_bytes(resp[0x22:0x24], "little", signed=True) == -7 + types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent] + assert types == [0x6A, 0x65] # one auth attempt, no blind retry + with pytest.raises(e.AuthorizationError): + e.check_error(resp[0x22:0x24]) + + +def test_locked_device_surfaces_as_the_original_error(net): + """Device locked in the app: request answered -7, auth answered -1. The + caller gets the -7 frame back (its check_error raises + AuthorizationError), and its own auth() call then sees the -1.""" + dev = fixed_device() + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + + def sendto(data, addr=None): + ep.sent.append((bytes(data), addr or ep.remote_addr)) + ptype = int.from_bytes(data[0x26:0x28], "little") + error = 0xFFFF if ptype == 0x65 else 0xFFF9 # -1 to auth, -7 to requests + ep.protocol.queue.put_nowait((make_response(dev, b"", error=error), HOST)) + + ep.sendto = sendto + resp = await dev.send_packet(0x6A, b"") + code = int.from_bytes(resp[0x22:0x24], "little", signed=True) + with pytest.raises(e.AuthenticationError): + await dev.auth() + return ep, code + + ep, code = run(go()) + assert code == -7 + types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent] + assert types == [0x6A, 0x65, 0x65] + + +def test_request_queued_behind_an_auth_still_reauths_if_needed(net): + """The auth generation is read under the lock, so a request that was + queued while another caller's auth() ran, and still gets -7, performs + its own re-authentication instead of assuming the earlier one covers + it.""" + 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) + renewed = fixed_device() + renewed.update_aes(session_key) + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + auths = {"n": 0} + loop = asyncio.get_running_loop() + + def sendto(data, addr=None): + ep.sent.append((bytes(data), addr or ep.remote_addr)) + ptype = int.from_bytes(data[0x26:0x28], "little") + if ptype == 0x65: + auths["n"] += 1 + reply = auth_reply + elif auths["n"] < 2: + reply = make_response(dev, b"", error=0xFFF9) # still -7 after auth #1 + else: + reply = make_response(renewed, bytes([9]) + bytes(15)) + loop.call_later(0.002, ep.protocol.queue.put_nowait, (reply, ep.remote_addr)) + + ep.sendto = sendto + first_auth = loop.create_task(dev.auth()) + await asyncio.sleep(0) # let auth() take the lock first + resp = await dev.send_packet(0x6A, b"") + await first_auth + return ep, resp + + ep, resp = run(go()) + types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent] + assert types == [0x65, 0x6A, 0x65, 0x6A] + assert dev.decrypt(resp[0x38:])[0] == 9 + + +def test_concurrent_callers_share_one_reauth(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) + renewed = fixed_device() + renewed.update_aes(session_key) + counters = {"value": 1} + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + authed = {"done": False} + + def sendto(data, addr=None): + ep.sent.append((bytes(data), addr or ep.remote_addr)) + ptype = int.from_bytes(data[0x26:0x28], "little") + if ptype == 0x65: + authed["done"] = True + reply = auth_reply + elif not authed["done"]: + reply = make_response(dev, b"", error=0xFFF9) # -7 expired + else: + n = counters["value"] + counters["value"] += 1 + reply = make_response(renewed, bytes([n]) + bytes(15)) + ep.protocol.queue.put_nowait((reply, ep.remote_addr)) + + ep.sendto = sendto + a, b = await asyncio.gather( + dev.send_packet(0x6A, b"a"), dev.send_packet(0x6A, b"b") + ) + return ep, {dev.decrypt(a[0x38:])[0], dev.decrypt(b[0x38:])[0]} + + ep, values = run(go()) + types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent] + assert types.count(0x65) == 1 # exactly one auth despite two expired requests + assert values == {1, 2} + + +def test_logged_out_code_triggers_reauth(net): + dev = fixed_device() + session_key = bytes.fromhex("00112233445566778899aabbccddeeff") + fresh = fixed_device() + auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key) + renewed = fixed_device() + renewed.update_aes(session_key) + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + ep.replies = [ + (make_response(dev, b"", error=0xFFFE), HOST), # -2 logged out + (auth_reply, HOST), + (make_response(renewed, bytes([3]) + bytes(15)), HOST), + ] + resp = await dev.send_packet(0x6A, b"") + return dev.decrypt(resp[0x38:])[0] + + assert run(go()) == 3 + + +# ------------------------------------------------------------------- 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