From ace240a035f597aaf6a386cffb55fe50832e6925 Mon Sep 17 00:00:00 2001 From: David <128871138+DAB-LABS@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:39:13 -0700 Subject: [PATCH 1/8] Add packaging, CI, publishing workflow and a byte-level test suite (#1) Move packaging to pyproject.toml as the python-broadlink distribution (import name unchanged), require Python 3.13 or newer, and replace the flake8 workflow with ruff and pytest on 3.13 and 3.14 plus an sdist and wheel build. Add a trusted-publishing workflow for version tags. Add tests/oracle: a harness that records the exact request bytes every public method of every device class sends and the result it decodes from canned responses, frozen in fixtures.json (155 cases), plus transport tests for send_packet framing, checksums, auth, discovery, gendevice and setup, and tests for the pure helpers. No library behavior changes; three import blocks were reordered for ruff. Add a README note explaining the fork and a CHANGELOG. --- .github/PULL_REQUEST_TEMPLATE.md | 24 +- .github/workflows/ci.yml | 49 + .github/workflows/flake8.yaml | 33 - .github/workflows/publish.yml | 46 + .gitignore | 11 + CHANGELOG.md | 27 + MANIFEST.in | 5 + README.md | 22 +- broadlink/__init__.py | 2 +- broadlink/device.py | 2 +- broadlink/hub.py | 2 +- pyproject.toml | 66 + requirements.txt | 1 - setup.py | 29 - tests/__init__.py | 0 tests/oracle/__init__.py | 1 + tests/oracle/cases.py | 402 +++ tests/oracle/fixtures.json | 4099 ++++++++++++++++++++++++++++++ tests/oracle/harness.py | 158 ++ tests/oracle/record.py | 38 + tests/test_helpers.py | 93 + tests/test_oracle.py | 54 + tests/test_transport.py | 383 +++ 23 files changed, 5463 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/flake8.yaml create mode 100644 .github/workflows/publish.yml create mode 100644 CHANGELOG.md create mode 100644 MANIFEST.in create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/oracle/__init__.py create mode 100644 tests/oracle/cases.py create mode 100644 tests/oracle/fixtures.json create mode 100644 tests/oracle/harness.py create mode 100644 tests/oracle/record.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_oracle.py create mode 100644 tests/test_transport.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index de98d3df..19057e2c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,10 +1,8 @@ ## Context - [ ] Dependency upgrade @@ -41,15 +38,10 @@ - This PR fixes issue: fixes # - This PR is related to: -- Link to documentation pull request: ## Checklist - -- [ ] The code change is tested and works locally. -- [ ] The code has been formatted using Black. -- [ ] The code follows the [Zen of Python](https://www.python.org/dev/peps/pep-0020/). -- [ ] I am creating the Pull Request against the correct branch. -- [ ] Documentation added/updated. +- [ ] The code change is tested and works locally (`pytest`). +- [ ] `ruff check .` passes. +- [ ] New device support was verified on real hardware, or the PR says it was not. +- [ ] `CHANGELOG.md` has an entry under Unreleased. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ec1ee0c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Test + run: pytest + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build + python -m build + - name: Check the wheel imports + run: | + python -m venv /tmp/check + /tmp/check/bin/pip install dist/*.whl + /tmp/check/bin/python -c "import broadlink; print(broadlink.__name__)" + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/flake8.yaml b/.github/workflows/flake8.yaml deleted file mode 100644 index aa09a19c..00000000 --- a/.github/workflows/flake8.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Python flake8 - -on: - push: - branches: [ master, dev ] - pull_request: - branches: [ master, dev ] - -jobs: - test: - runs-on: ubuntu-20.04 - strategy: - matrix: - python-version: [3.6, 3.7, 3.8, 3.9] - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install wheel - pip install flake8 flake8-quotes - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. ignore magic numbers and use double quotes and ignore numbers with zeroes before them. - # and ignore lowercase hex numbers and ignore isort incorrect imports - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=90 --ignore=WPS432,WPS339,WPS341,I --inline-quotes double --statistics diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..833ab776 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: Publish to PyPI + +# Runs on a version tag (v1.0.0, v1.0.1, ...). Uses PyPI trusted publishing: +# the project on PyPI is configured to trust this repository, this workflow +# file name, and the "pypi" environment. No API token is stored anywhere. + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check the tag matches the package version + run: | + TAG="${GITHUB_REF_NAME#v}" + VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + echo "tag=$TAG version=$VERSION" + test "$TAG" = "$VERSION" + - name: Build + run: | + python -m pip install --upgrade pip build + python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 0d20b648..ef2edb2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,12 @@ *.pyc +__pycache__/ +*.egg-info/ +build/ +dist/ +.venv/ +.pytest_cache/ +.ruff_cache/ +.DS_Store + +# Working notes that are not part of the published project. +docs/internal/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6ef4e048 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project are recorded here. The format follows +Keep a Changelog; versions follow Semantic Versioning. + +## Unreleased + +This is the first release of `python-broadlink`, a maintained fork of +`mjg59/python-broadlink` (PyPI `broadlink`, last released as 0.19.0). The +history below starts at that fork point. + +### Changed + +- Packaging moved to `pyproject.toml`; `setup.py` and the stale + `requirements.txt` pin are gone. The distribution name is now + `python-broadlink`; the import name stays `broadlink`. Python 3.13 or + newer is required. +- Continuous integration now runs `ruff` and `pytest` on Python 3.13 and + 3.14, and builds the sdist and wheel on every pull request. Releases are + published to PyPI from version tags using trusted publishing. + +### Added + +- A test suite. The `tests/oracle` package records the exact request bytes + every public method of every device class sends, and the results it + decodes from canned responses, so that later changes to the transport + can be checked byte for byte against the original behavior. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..2422af09 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include LICENSE README.md CHANGELOG.md protocol.md TROUBLESHOOTING.md +include pyproject.toml +graft cli +graft tests +global-exclude __pycache__ *.py[cod] .DS_Store diff --git a/README.md b/README.md index 81c6de5b..34a82386 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ # python-broadlink -A Python module and CLI for controlling Broadlink devices locally. The following devices are supported: +A Python module and CLI for controlling Broadlink devices locally. + +> **About this fork.** This repository is a maintained fork of +> [mjg59/python-broadlink](https://github.com/mjg59/python-broadlink), which +> has not accepted changes since 2024. It exists so that Home Assistant's +> Broadlink integration has a library that can take fixes and new devices. +> The distribution on PyPI is `python-broadlink`; the import name stays +> `broadlink`. The first release corrects the IR timing constant reported in +> upstream [#839](https://github.com/mjg59/python-broadlink/issues/839) +> (fix in [#841](https://github.com/mjg59/python-broadlink/pull/841)) and +> adds the devices waiting in upstream's pull request queue, including the +> RM Max and RM5 Plus. Version 1.0 will be asynchronous; see `CHANGELOG.md`. +> Upstream's credit and MIT license are preserved. + +The following devices are supported: - **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate - **Smart plugs**: SP mini, SP mini 3, SP mini+, SP1, SP2, SP2-BR, SP2-CL, SP2-IN, SP2-UK, SP3, SP3-EU, SP3S-EU, SP3S-US, SP4L-AU, SP4L-EU, SP4L-UK, SP4M, SP4M-US, Ankuoo NEO, Ankuoo NEO PRO, Efergy Ego, BG AHC/U-01 @@ -19,9 +33,13 @@ A Python module and CLI for controlling Broadlink devices locally. The following Use pip3 to install the latest version of this module. ``` -pip3 install broadlink +pip3 install python-broadlink ``` +If the original `broadlink` distribution is also installed in the same +environment, remove it first (`pip3 uninstall broadlink`); both provide the +`broadlink` package. + ## Basic functions First, open Python 3 and import this module. diff --git a/broadlink/__init__.py b/broadlink/__init__.py index d3135501..b2fa3d9a 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -4,9 +4,9 @@ from typing import Generator, List, Optional, Tuple, Union from . import exceptions as e -from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT from .alarm import S1C from .climate import hvac, hysen +from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT from .cover import dooya, dooya2, wser from .device import Device, ping, scan from .hub import s3 diff --git a/broadlink/device.py b/broadlink/device.py index 5a10bc01..22c3ebed 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -1,7 +1,7 @@ """Support for Broadlink devices.""" +import random import socket import threading -import random import time from typing import Generator, Optional, Tuple, Union diff --git a/broadlink/hub.py b/broadlink/hub.py index 0fd4ae53..40dd8e2d 100644 --- a/broadlink/hub.py +++ b/broadlink/hub.py @@ -1,6 +1,6 @@ """Support for hubs.""" -import struct import json +import struct from typing import Optional from . import exceptions as e diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7c223b33 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-broadlink" +version = "1.0.0.dev0" +description = "Python API for controlling Broadlink devices" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.13" +authors = [ + { name = "Matthew Garrett", email = "mjg59@srcf.ucam.org" }, + { name = "DAB-LABS" }, +] +maintainers = [ + { name = "DAB-LABS" }, +] +keywords = ["broadlink", "infrared", "rf", "home-assistant", "rm4", "rm-pro"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Home Automation", +] +dependencies = [ + "cryptography>=3.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "ruff>=0.5", + "build", +] + +[project.urls] +Homepage = "https://github.com/DAB-LABS/python-broadlink" +Repository = "https://github.com/DAB-LABS/python-broadlink" +Issues = "https://github.com/DAB-LABS/python-broadlink/issues" +Changelog = "https://github.com/DAB-LABS/python-broadlink/blob/master/CHANGELOG.md" +Upstream = "https://github.com/mjg59/python-broadlink" + +[tool.setuptools] +packages = ["broadlink"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 90 +target-version = "py313" + +[tool.ruff.lint] +# Start from the upstream flake8 gate (syntax errors and undefined names) +# plus pyflakes and import hygiene. Style rules widen once the async port lands. +select = ["E9", "F", "I"] + +[tool.ruff.lint.per-file-ignores] +# The package __init__ re-exports the public API. +"broadlink/__init__.py" = ["F401"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2c6c996c..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -cryptography==3.2 diff --git a/setup.py b/setup.py deleted file mode 100644 index 0426f148..00000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -from setuptools import setup, find_packages - - -version = '0.19.0' - -setup( - name="broadlink", - version=version, - author="Matthew Garrett", - author_email="mjg59@srcf.ucam.org", - url="http://github.com/mjg59/python-broadlink", - packages=find_packages(), - scripts=[], - install_requires=["cryptography>=3.2"], - description="Python API for controlling Broadlink devices", - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - ], - include_package_data=True, - zip_safe=False, -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/oracle/__init__.py b/tests/oracle/__init__.py new file mode 100644 index 00000000..40616ad4 --- /dev/null +++ b/tests/oracle/__init__.py @@ -0,0 +1 @@ +"""Byte-level oracle for the device classes. See harness.py.""" diff --git a/tests/oracle/cases.py b/tests/oracle/cases.py new file mode 100644 index 00000000..df782acb --- /dev/null +++ b/tests/oracle/cases.py @@ -0,0 +1,402 @@ +"""The oracle cases: one entry per public method per device class. + +Each case names a device class and product id, a method with arguments, and +the canned response payloads (plaintext, before encryption) the fake device +answers with, in order. ``record.py`` runs them against the library and +freezes the outcome in ``fixtures.json``; ``test_oracle.py`` replays them +and compares. + +Canned payloads are shaped for each class's decoder so the method exercises +its full parse path. Comments say which bytes each decoder reads. +""" + +from __future__ import annotations + +import json +import struct + +from broadlink.helpers import CRC16 + + +def hexb(*parts: bytes | bytearray) -> str: + return b"".join(bytes(p) for p in parts).hex() + + +def b(value: bytes | bytearray) -> dict: + """Wrap bytes for JSON storage.""" + return {"__bytes__": bytes(value).hex()} + + +# ---------------------------------------------------------------- payload builders + + +def rmmini_payload(body: bytes) -> str: + """rmmini._send returns payload[4:]; the first four bytes echo the command.""" + return hexb(b"\x01\x00\x00\x00", body) + + +def rmminib_payload(body: bytes) -> str: + """rmminib._send reads p_len at [0:2] and returns payload[6:p_len+2].""" + p_len = len(body) + 4 + return hexb(struct.pack(" str: + """hysen.send_request: [len][body][crc16(body)]; returns body.""" + p_len = len(body) + 2 + return hexb(struct.pack(" str: + """hvac._decode: [len][bb 00 07 00 00 00][d_len][data][crc16 poly 0x9BE4].""" + p_len = 10 + len(data) + head = struct.pack(" str: + """12-byte header: js_len at 0x08, JSON at 0x0C (sp4, lb2, s3).""" + data = json.dumps(state, separators=(",", ":")).encode() + head = struct.pack(" str: + """14-byte header: js_len at 0x0A, JSON at 0x0E (sp4b, bg1, lb1).""" + data = json.dumps(state, separators=(",", ":")).encode() + head = struct.pack(" str: + p = bytearray(0x10) + p[0x04], p[0x05] = temp + p[0x06], p[0x07] = hum + p[0x08] = light + p[0x0A] = air + p[0x0C] = noise + return p.hex() + + +def a2_payload() -> str: + p = bytearray(0x18) + p[0x0D:0x0F] = (12).to_bytes(2, "big") # pm10 + p[0x0F:0x11] = (7).to_bytes(2, "big") # pm2_5 + p[0x11:0x13] = (3).to_bytes(2, "big") # pm1 + p[0x13:0x15] = (235).to_bytes(2, "big") # temperature + p[0x15:0x17] = (452).to_bytes(2, "big") # humidity + return p.hex() + + +def mp1s_payload() -> str: + """mp1s.get_state slices payload.hex()[4:-6] and reads BCD digit pairs.""" + digits = "".join(str(i % 10) for i in range(54)) + return "0000" + digits + "000000" + + +def s1c_payload() -> str: + def sensor(status, order, stype, name, serial): + s = bytearray(83) + s[0] = status + s[1] = order + s[3] = stype + s[4 : 4 + len(name)] = name.encode() + s[26:30] = serial + return bytes(s) + + p = bytearray(6) + p[4] = 2 + return hexb( + p, + sensor(1, 1, 0x31, "Front door", b"\x01\x02\x03\x04"), + sensor(0, 2, 0x21, "Hall", b"\x0a\x0b\x0c\x0d"), + sensor(0, 3, 0x91, "", b"\x00\x00\x00\x00"), # empty serial: filtered out + ) + + +def hysen_status_body() -> bytes: + body = bytearray(48) + body[3] = 0x01 # remote_lock + body[4] = 0b1101_0001 # heating_cooling=1, temp_manual=1, active=1, offset add=0, power=1 + body[5] = 43 # room temp 21.5 + body[6] = 44 # thermostat temp 22.0 + body[7] = 0x21 # loop_mode 2, auto_mode 1 + body[8] = 0 # sensor + body[9] = 42 # osv + body[10] = 2 # dif + body[11] = 35 # svh + body[12] = 5 # svl + body[13:15] = (-5).to_bytes(2, "big", signed=True) # room_temp_adj -0.5 + body[15] = 0 # fre + body[16] = 1 # poweron + body[17] = 0x20 # unknown (offset raw 2) + body[18] = 50 # external temp 25.0 + body[19], body[20], body[21], body[22] = 14, 30, 5, 3 + for i in range(8): + body[2 * i + 23] = 6 + i + body[2 * i + 24] = 15 + body[i + 39] = 40 + i + return bytes(body) + + +def hvac_state_data() -> bytes: + data = bytearray(2 + 13) + s = memoryview(data)[2:] + s[0x00] = (int(24) - 8 << 3) | 2 # target 24, swing_v POS2 + s[0x01] = (7 << 5) | 0b100 # swing_h OFF + s[0x03] = 2 << 5 # speed MID + s[0x04] = 1 << 6 # preset TURBO (bits 6-7; bit 7 doubles as the half degree) + s[0x05] = (1 << 5) | (1 << 2) # mode COOL, sleep + s[0x08] = (1 << 5) | (1 << 2) | 0b11 # power, clean, health + s[0x0A] = (1 << 4) # display + return bytes(data) + + +def hvac_info_data() -> bytes: + data = bytearray(2 + 22) + s = memoryview(data)[2:] + s[0x01] = 1 + s[0x05] = 26 + s[0x15] = 5 + return bytes(data) + + +def fw_payload(version: int) -> str: + p = bytearray(8) + p[4:6] = version.to_bytes(2, "little") + return p.hex() + + +def rm_update_payload(name: str, locked: bool) -> str: + body = bytearray(0x88) + body[0x48 : 0x48 + len(name)] = name.encode() + body[0x87] = int(locked) + return rmmini_payload(bytes(body)) + + +def rmminib_update_payload(name: str, locked: bool) -> str: + body = bytearray(0x88) + body[0x48 : 0x48 + len(name)] = name.encode() + body[0x87] = int(locked) + return rmminib_payload(bytes(body)) + + +IR_CODE = bytes.fromhex("2600180012341234123412340d05") +EMPTY = "00" * 16 + +# ------------------------------------------------------------------------ cases + + +def case(cls, devtype, method, *args, responses=(), attrs=(), setup=None, **kwargs): + entry = { + "cls": cls, + "devtype": devtype, + "method": method, + "args": list(args), + "kwargs": kwargs, + "responses": list(responses), + } + if attrs: + entry["attrs"] = list(attrs) + if setup: + entry["setup"] = setup + return entry + + +def all_cases() -> list[dict]: + cases: list[dict] = [] + add = cases.append + + # Device base ------------------------------------------------------- + add(case("Device", 0x0000, "get_fwversion", responses=[fw_payload(0x1234)])) + add(case("Device", 0x0000, "set_name", "Living room", responses=[EMPTY], attrs=["name"])) + add(case("Device", 0x0000, "set_lock", True, responses=[EMPTY], attrs=["is_locked"])) + add(case("Device", 0x0000, "set_lock", False, responses=[EMPTY], attrs=["is_locked"], + setup={"name": "Kitchen"})) + add(case("Device", 0x0000, "get_type")) + + # RM family --------------------------------------------------------- + for cls, devtype, payload in (("rmmini", 0x2737, rmmini_payload), + ("rmpro", 0x272A, rmmini_payload), + ("rmminib", 0x5F36, rmminib_payload), + ("rm4mini", 0x51DA, rmminib_payload), + ("rm4pro", 0x6026, rmminib_payload), + ("rm", 0x2712, rmmini_payload), + ("rm4", 0x62BE, rmminib_payload)): + add(case(cls, devtype, "send_data", b(IR_CODE), responses=[payload(b"")])) + add(case(cls, devtype, "enter_learning", responses=[payload(b"")])) + add(case(cls, devtype, "check_data", responses=[payload(IR_CODE)])) + upd = rmminib_update_payload if payload is rmminib_payload else rm_update_payload + add(case(cls, devtype, "update", responses=[upd("Bedroom RM", True)], + attrs=["name", "is_locked"])) + + for cls, devtype in (("rmpro", 0x272A), ("rm", 0x2712)): + add(case(cls, devtype, "check_sensors", responses=[rmmini_payload(bytes([23, 4]))])) + add(case(cls, devtype, "check_temperature", responses=[rmmini_payload(bytes([23, 4]))])) + + for cls, devtype in (("rm4mini", 0x51DA), ("rm4pro", 0x6026), ("rm4", 0x62BE)): + body = bytes([24, 35, 51, 20]) + add(case(cls, devtype, "check_sensors", responses=[rmminib_payload(body)])) + add(case(cls, devtype, "check_temperature", responses=[rmminib_payload(body)])) + add(case(cls, devtype, "check_humidity", responses=[rmminib_payload(body)])) + + for cls, devtype, payload in (("rmpro", 0x272A, rmmini_payload), + ("rm4pro", 0x6026, rmminib_payload), + ("rm", 0x2712, rmmini_payload), + ("rm4", 0x62BE, rmminib_payload)): + add(case(cls, devtype, "sweep_frequency", responses=[payload(b"")])) + found = bytes([1]) + struct.pack(" list[dict]: + """Cases whose canned response carries a device error code.""" + return [ + {"cls": "rmmini", "devtype": 0x2737, "method": "enter_learning", + "args": [], "kwargs": {}, "responses": [EMPTY], "error_code": 0xFFFB}, + {"cls": "sp2", "devtype": 0x2711, "method": "check_power", + "args": [], "kwargs": {}, "responses": [EMPTY], "error_code": 0xFFF9}, + ] diff --git a/tests/oracle/fixtures.json b/tests/oracle/fixtures.json new file mode 100644 index 00000000..782b2bae --- /dev/null +++ b/tests/oracle/fixtures.json @@ -0,0 +1,4099 @@ +[ + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "get_fwversion", + "args": [], + "kwargs": {}, + "responses": [ + "0000000034120000" + ] + }, + "expect": { + "result": 4660, + "sent": [ + [ + 106, + "68" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_name", + "args": [ + "Living room" + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "name" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "000000004c6976696e6720726f6f6d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Living room" + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_lock", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0000000042656e63680000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "is_locked": true + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_lock", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "is_locked" + ], + "setup": { + "name": "Kitchen" + } + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "000000004b69746368656e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "is_locked": false + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "get_type", + "args": [], + "kwargs": {}, + "responses": [] + }, + "expect": { + "result": "Unknown", + "sent": [], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": { + "temperature": 23.4 + }, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": 23.4, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": { + "temperature": 23.4 + }, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": 23.4, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "19000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0100000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "010000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040019000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "09000000000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0900000000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08001b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "19000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0100000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "010000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040019000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "09000000000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0900000000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08001b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp1", + "devtype": 0, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 102, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp1", + "devtype": 0, + "method": "set_power", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 102, + "00000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "02000000010000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "02000000010000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2s", + "devtype": 10024, + "method": "get_energy", + "args": [], + "kwargs": {}, + "responses": [ + "00000000d20400000000000000000000" + ] + }, + "expect": { + "result": 1.234, + "sent": [ + [ + 106, + "04000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "get_energy", + "args": [], + "kwargs": {}, + "responses": [ + "00000000003412000000000000000000" + ] + }, + "expect": { + "result": 12.34, + "sent": [ + [ + 106, + "0800fe0105010000002d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000", + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ], + [ + 106, + "02000000030000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "set_nightlight", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000", + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ], + [ + 106, + "02000000030000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "check_nightlight", + "args": [], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "a5a55a5ac3c3020b090000007b22707772223a317d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_nightlight", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "a5a55a5a67c5020b0d0000007b226e746c69676874223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": true, + "ntlbrightness": 25, + "childlock": true + }, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0 + }, + "sent": [ + [ + 106, + "a5a55a5a04cf020b2a0000007b22707772223a312c226e746c6272696768746e657373223a32352c226368696c646c6f636b223a317d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "check_nightlight", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0, + "current": 0.12, + "volt": 230.5, + "power": 27.6, + "overload": 0.0 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": false + }, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0, + "current": 120, + "volt": 230500, + "power": 27600, + "totalconsum": -1, + "overload": 0 + }, + "sent": [ + [ + 106, + "1500a5a55a5ac2c3020b090000007b22707772223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "bg1", + "devtype": 20963, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "bg1", + "devtype": 20963, + "method": "set_state", + "args": [], + "kwargs": { + "pwr1": true, + "maxworktime2": 15 + }, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "2b00a5a55a5a64ca020b1f0000007b2270777231223a20312c20226d6178776f726b74696d6532223a2031357d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "ehc31", + "devtype": 25728, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "ehc31", + "devtype": 25728, + "method": "set_state", + "args": [], + "kwargs": { + "pwr3": true, + "childlock": true, + "childlock4": false + }, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "3800a5a55a5afccd020b2c0000007b2270777233223a20312c20226368696c646c6f636b223a20312c20226368696c646c6f636b34223a20307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power_mask", + "args": [ + 5, + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5abcc00200030000050500" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power", + "args": [ + 1, + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5ab4c00200030000010100" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power", + "args": [ + 3, + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5ab6c00200030000040000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "check_power_raw", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": 11, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": { + "s1": true, + "s2": true, + "s3": false, + "s4": true + }, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1s", + "devtype": 20251, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "0000012345678901234567890123456789012345678901234567890123000000" + ] + }, + "expect": { + "result": { + "volt": 230.1, + "current": 89.6745, + "power": 4523.01, + "totalconsum": 230189.67 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab2c00100040000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1s", + "devtype": 20251, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": { + "s1": true, + "s2": true, + "s3": false, + "s4": true + }, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020200010000000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": "normal", + "air_quality": "good", + "noise": "quiet" + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020900090009000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": "unknown", + "air_quality": "unknown", + "noise": "unknown" + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors_raw", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020200010000000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": 2, + "air_quality": 1, + "noise": 0 + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a2", + "devtype": 20320, + "method": "check_sensors_raw", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000c0007000300eb01c400" + ] + }, + "expect": { + "result": { + "temperature": 235, + "humidity": 452, + "pm10": 12, + "pm2_5": 7, + "pm1": 3 + }, + "sent": [ + [ + 106, + "0a00a5a55a5ab9c0010b0000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb1", + "devtype": 24775, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "e500a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb1", + "devtype": 24775, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": true, + "brightness": 50, + "bulb_colormode": 1, + "bulb_scene": "" + }, + "responses": [ + "e500a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "4800a5a55a5ae1d4020b3c0000007b22707772223a312c226272696768746e657373223a35302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e65223a22227d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb2", + "devtype": 42228, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb2", + "devtype": 42228, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": false, + "red": 1, + "green": 2, + "blue": 3, + "transitionduration": 200 + }, + "responses": [ + "a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "a5a55a5a6bd4020b3d0000007b22707772223a302c22726564223a312c22626c7565223a332c22677265656e223a322c227472616e736974696f6e6475726174696f6e223a3230307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "send_request", + "args": [ + [ + 1, + 3, + 0, + 0, + 0, + 8 + ] + ], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": { + "__bytes__": "00000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00" + }, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": 21.5, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_external_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": 25.0, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_full_status", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": { + "remote_lock": 1, + "power": 1, + "active": 1, + "temp_manual": 1, + "heating_cooling": 1, + "room_temp": 21.5, + "thermostat_temp": 22.0, + "auto_mode": 1, + "loop_mode": 2, + "sensor": 0, + "osv": 42, + "dif": 2, + "svh": 35, + "svl": 5, + "room_temp_adj": -0.5, + "fre": 0, + "poweron": 1, + "unknown": 32, + "external_temp": 25.0, + "hour": 14, + "min": 30, + "sec": 5, + "dayofweek": 3, + "weekday": [ + { + "start_hour": 6, + "start_minute": 15, + "temp": 20.0 + }, + { + "start_hour": 7, + "start_minute": 15, + "temp": 20.5 + }, + { + "start_hour": 8, + "start_minute": 15, + "temp": 21.0 + }, + { + "start_hour": 9, + "start_minute": 15, + "temp": 21.5 + }, + { + "start_hour": 10, + "start_minute": 15, + "temp": 22.0 + }, + { + "start_hour": 11, + "start_minute": 15, + "temp": 22.5 + } + ], + "weekend": [ + { + "start_hour": 12, + "start_minute": 15, + "temp": 23.0 + }, + { + "start_hour": 13, + "start_minute": 15, + "temp": 23.5 + } + ] + }, + "sent": [ + [ + 106, + "0800010300000016c404" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_mode", + "args": [ + 1, + 2 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08000106000231003d9a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_mode", + "args": [ + 0, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0800010600021001e40a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_advanced", + "args": [ + 0, + 0, + 42, + 2, + 35, + 5, + -0.5, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "13000110000200050a00002a022305fffb0001e8eb" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "switch_to_auto", + "args": [], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0800010600021100245a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "switch_to_manual", + "args": [], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060002100025ca" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_temp", + "args": [ + 21.5 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060001002b9815" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_power", + "args": [ + 1, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060000008149aa" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_time", + "args": [ + 14, + 30, + 5, + 3 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00011000080002040e1e0503d3b6" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_schedule", + "args": [ + [ + { + "start_hour": 6, + "start_minute": 15, + "temp": 20 + }, + { + "start_hour": 7, + "start_minute": 15, + "temp": 21 + }, + { + "start_hour": 8, + "start_minute": 15, + "temp": 22 + }, + { + "start_hour": 9, + "start_minute": 15, + "temp": 23 + }, + { + "start_hour": 10, + "start_minute": 15, + "temp": 24 + }, + { + "start_hour": 11, + "start_minute": 15, + "temp": 25 + } + ], + [ + { + "start_hour": 8, + "start_minute": 0, + "temp": 21 + }, + { + "start_hour": 22, + "start_minute": 30, + "temp": 17.5 + } + ] + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "21000110000a000c18060f070f080f090f0a0f0b0f0800161e282a2c2e30322a2312ca" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f0045e3" + ] + }, + "expect": { + "error": "DataValidationError: [Errno -4008] Received data packet check error: Expected a checksum of 58181 and received 7237", + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "0c00bb0006800000020011014768" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_ac_info", + "args": [], + "kwargs": {}, + "responses": [ + "2200bb00070000001800000000010000001a00000000000000000000000000000005a5e0" + ] + }, + "expect": { + "result": { + "power": 1, + "ambient_temp": 26.5 + }, + "sent": [ + [ + 106, + "0c00bb0006800000020021018f90" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "0d00bb000700000003000000013273" + ] + }, + "expect": { + "error": "DataValidationError: [Errno -4007] Received data packet length error: Expected at least 15 bytes and received 3", + "sent": [ + [ + 106, + "0c00bb0006800000020011014768" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 22.5, + 1, + 2, + 0, + 7, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "1900bb00068000000f00010170e48d4000200000200010000577f6" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 24, + 4, + 3, + 2, + 0, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "1900bb00068000000f00010180040d608080000020001000058a9b" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 24, + 2, + 1, + 1, + 0, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "error": "ValueError: turbo is only available in cooling/heating", + "sent": [], + "unused_responses": 1 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb010000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb020000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb030000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "get_percentage", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb065d00000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "set_percentage_and_wait", + "args": [ + 50 + ], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000", + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0900bb065d00000000fa440000000000" + ], + [ + 106, + "0900bb030000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5abec0020b03000000000100" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5abfc0020b03000000000200" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5ac0c0020b03000000000300" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "get_percentage", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": 40, + "sent": [ + [ + 106, + "0f00a5a55a5ac2c0010b03000000000600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "set_percentage", + "args": [ + 40 + ], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5aeec0020b03000000000928" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "get_position", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0a00a5a55a5ab9c0010b0000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5ad8c1020b030000004a31a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5af0c1020b030000006132a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5a1cc2020b030000004c73a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "set_position", + "args": [ + 30 + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5aebc1020b030000001e70a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_subdevices", + "args": [ + 2 + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b400000007b22746f74616c223a332c226c697374223a5b7b22646964223a226131222c2270777231223a317d2c7b22646964223a226132222c2270777231223a307d5d7d", + "a5a55a5a0000010b400000007b22746f74616c223a332c226c697374223a5b7b22646964223a226132222c2270777231223a307d2c7b22646964223a226133222c2270777231223a317d5d7d" + ] + }, + "expect": { + "result": [ + { + "did": "a1", + "pwr1": 1 + }, + { + "did": "a2", + "pwr1": 0 + }, + { + "did": "a3", + "pwr1": 1 + } + ], + "sent": [ + [ + 106, + "a5a55a5a9ec70e0b150000007b22636f756e74223a322c22696e646578223a307d" + ], + [ + 106, + "a5a55a5aa0c70e0b150000007b22636f756e74223a322c22696e646578223a327d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b0a0000007b2270777231223a317d" + ] + }, + "expect": { + "result": { + "pwr1": 1 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_state", + "args": [ + "a1" + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b0a0000007b2270777231223a317d" + ] + }, + "expect": { + "result": { + "pwr1": 1 + }, + "sent": [ + [ + 106, + "a5a55a5a42c4010b0c0000007b22646964223a226131227d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "set_state", + "args": [ + "a1", + true, + null, + false + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b130000007b2270777231223a312c2270777233223a307d" + ] + }, + "expect": { + "result": { + "pwr1": 1, + "pwr3": 0 + }, + "sent": [ + [ + 106, + "a5a55a5a20c9020b1e0000007b22646964223a226131222c2270777231223a312c2270777233223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "S1C", + "devtype": 10018, + "method": "get_sensors_status", + "args": [], + "kwargs": {}, + "responses": [ + "0000000002000101003146726f6e7420646f6f720000000000000000000000000102030400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002002148616c6c0000000000000000000000000000000000000a0b0c0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003009100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + }, + "expect": { + "result": { + "count": 2, + "sensors": [ + { + "status": 1, + "name": "Front door", + "type": "Door Sensor", + "order": 1, + "serial": "01020304" + }, + { + "status": 0, + "name": "Hall", + "type": "Motion Sensor", + "order": 2, + "serial": "0a0b0c0d" + } + ] + }, + "sent": [ + [ + 106, + "06000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [] + }, + "expect": { + "error": "AssertionError: method sent more packets than canned responses (1 sent)", + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "error_code": 65531 + }, + "expect": { + "error": "StorageError: [Errno -5] The device storage is full", + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "error_code": 65529 + }, + "expect": { + "error": "AuthorizationError: [Errno -7] Control key is expired", + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + } +] diff --git a/tests/oracle/harness.py b/tests/oracle/harness.py new file mode 100644 index 00000000..9fb8d2d0 --- /dev/null +++ b/tests/oracle/harness.py @@ -0,0 +1,158 @@ +"""Harness that records what device methods send and what they decode. + +Every public method on a device class ends up calling ``Device.send_packet`` +with a packet type and a plaintext payload, and then decoding whatever the +device answers. The transport (framing, encryption, retries) lives in +``send_packet`` itself and is tested separately. This harness replaces +``send_packet`` on one device instance so that: + +- each call is recorded as ``(packet_type, payload)`` before encryption, and +- each call is answered with a well-formed response frame carrying the next + canned payload, encrypted with the device's current session key so that the + method's own ``decrypt`` sees exactly those bytes. + +The recorded sequence and the method's return value are the "oracle": a +later reimplementation of the same method (for example, an asynchronous one) +must produce the same sequence and the same result from the same canned +responses. Results are normalized to plain JSON so they can be stored. + +The runner accepts awaitables so the same cases can drive an asynchronous +``send_packet`` later without changing the cases. +""" + +from __future__ import annotations + +import asyncio +import enum +import inspect +from dataclasses import dataclass, field +from typing import Any + +import broadlink +from broadlink.device import Device + +# A fixed identity so recorded bytes never depend on random state. +MAC = bytes.fromhex("a043b05510f7") +HOST = ("192.0.2.10", 80) + + +def pad16(payload: bytes) -> bytes: + """Pad to the AES block size, as the device does before encrypting.""" + return bytes(payload) + bytes((16 - len(payload)) % 16) + + +def make_response(device: Device, payload: bytes, error: int = 0) -> bytes: + """Build a response frame the way a device would answer ``send_packet``. + + Only the parts the device classes read are meaningful: the error code + at 0x22:0x24 and the encrypted payload from 0x38. The frame checksum is + filled in so the frame would also pass ``send_packet``'s own check. + """ + frame = bytearray(0x38) + frame[0x00:0x08] = bytes.fromhex("5aa5aa555aa5aa55") + frame[0x22:0x24] = (error & 0xFFFF).to_bytes(2, "little") + frame[0x24:0x26] = device.devtype.to_bytes(2, "little") + frame[0x2A:0x30] = device.mac[::-1] + frame.extend(device.encrypt(pad16(payload))) + checksum = sum(frame, 0xBEAF) & 0xFFFF + frame[0x20:0x22] = checksum.to_bytes(2, "little") + return bytes(frame) + + +@dataclass +class Recorder: + """Replacement ``send_packet`` that records requests and serves responses.""" + + device: Device + responses: list[bytes] + error: int = 0 + sent: list[tuple[int, bytes]] = field(default_factory=list) + + def __call__(self, packet_type: int, payload: bytes) -> bytes: + self.sent.append((packet_type, bytes(payload))) + if not self.responses: + raise AssertionError( + f"method sent more packets than canned responses " + f"({len(self.sent)} sent)" + ) + return make_response(self.device, self.responses.pop(0), self.error) + + async def async_call(self, packet_type: int, payload: bytes) -> bytes: + return self(packet_type, payload) + + +def normalize(value: Any) -> Any: + """Turn a method result into plain JSON-compatible data.""" + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, (bytes, bytearray)): + return {"__bytes__": bytes(value).hex()} + if isinstance(value, dict): + return {str(k): normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [normalize(v) for v in value] + if isinstance(value, float): + return round(value, 6) + return value + + +def build_device(cls_name: str, devtype: int) -> Device: + """Instantiate a device class by name with the fixed test identity.""" + cls = getattr(broadlink, cls_name) + return cls(HOST, MAC, devtype, name="Bench", model="Test", manufacturer="Test") + + +def run_case(case: dict) -> dict: + """Execute one case and return the recorded outcome. + + ``case`` has: ``cls``, ``devtype``, ``method``, ``args``, ``kwargs``, + ``responses`` (list of hex payloads), and optionally ``setup`` (attribute + values applied before the call) and ``attrs`` (attribute names to record + after the call). + """ + device = build_device(case["cls"], case["devtype"]) + for name, value in case.get("setup", {}).items(): + setattr(device, name, value) + + responses = [bytes.fromhex(r) for r in case.get("responses", [])] + recorder = Recorder(device, responses, case.get("error_code", 0)) + target = getattr(device, "send_packet") + if inspect.iscoroutinefunction(target): + device.send_packet = recorder.async_call # type: ignore[method-assign] + else: + device.send_packet = recorder # type: ignore[method-assign] + + method = getattr(device, case["method"]) + args = [decode_arg(a) for a in case.get("args", [])] + kwargs = {k: decode_arg(v) for k, v in case.get("kwargs", {}).items()} + + outcome: dict[str, Any] = {} + try: + result = method(*args, **kwargs) + if inspect.isawaitable(result): + result = asyncio.run(_await(result)) + outcome["result"] = normalize(result) + except Exception as err: # noqa: BLE001 - the error type IS the oracle + outcome["error"] = f"{type(err).__name__}: {err}" + + outcome["sent"] = [[ptype, payload.hex()] for ptype, payload in recorder.sent] + outcome["unused_responses"] = len(recorder.responses) + attrs = case.get("attrs", []) + if attrs: + outcome["attrs"] = {a: normalize(getattr(device, a)) for a in attrs} + return outcome + + +async def _await(awaitable): + return await awaitable + + +def decode_arg(value: Any) -> Any: + """Cases store bytes arguments as {"__bytes__": hex}.""" + if isinstance(value, dict) and set(value) == {"__bytes__"}: + return bytes.fromhex(value["__bytes__"]) + if isinstance(value, list): + return [decode_arg(v) for v in value] + if isinstance(value, dict): + return {k: decode_arg(v) for k, v in value.items()} + return value diff --git a/tests/oracle/record.py b/tests/oracle/record.py new file mode 100644 index 00000000..82e8ccc6 --- /dev/null +++ b/tests/oracle/record.py @@ -0,0 +1,38 @@ +"""Record the oracle fixtures from the current library. + +Run from the repository root: + + python -m tests.oracle.record + +This overwrites ``tests/oracle/fixtures.json``. Only run it when the recorded +behavior is meant to change (for example, a deliberate protocol fix), and +review the diff of the fixture file in the same pull request. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from . import cases as case_module +from .harness import run_case + +FIXTURES = Path(__file__).with_name("fixtures.json") + + +def build() -> list[dict]: + entries = [] + for case in case_module.all_cases() + case_module.error_cases(): + outcome = run_case(case) + entries.append({"case": case, "expect": outcome}) + return entries + + +def main() -> None: + entries = build() + FIXTURES.write_text(json.dumps(entries, indent=1) + "\n") + print(f"recorded {len(entries)} cases to {FIXTURES}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 00000000..04629daf --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,93 @@ +"""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 data_to_pulses, pulses_to_data + +# NOTE: these pin the 0.19.0 behavior of the pulse helpers, including the +# 32.84 tick that upstream issue #839 identifies as wrong. They are expected +# to change, deliberately and in the same pull request, when the tick fix +# lands; until then they document what shipped. + + +def test_pulses_to_data_header_and_short_pulses(): + data = pulses_to_data([328, 656], tick=32.84) + assert data[0] == 0x26 + assert data[1] == 0x00 + assert int.from_bytes(data[2:4], "little") == 2 + assert data[4:] == bytes([9, 19]) # floor(328/32.84)=9, floor(656/32.84)=19 + + +def test_pulses_to_data_long_pulse_uses_three_byte_form(): + data = pulses_to_data([10000], tick=32.84) + ticks = int(10000 // 32.84) # 304 + assert data[4:] == bytes([0, ticks >> 8, ticks & 0xFF]) + assert int.from_bytes(data[2:4], "little") == 3 + + +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) + # Both directions use the same tick, so the round trip lands within a tick. + for a, b in zip(pulses, back, strict=True): + assert abs(a - b) <= 33 + + +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..7ae7d26d --- /dev/null +++ b/tests/test_oracle.py @@ -0,0 +1,54 @@ +"""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"} + 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_transport.py b/tests/test_transport.py new file mode 100644 index 00000000..7fd8e784 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,383 @@ +"""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 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 FakeSocket: + """A UDP socket stand-in: records sendto, replays canned recvfrom.""" + + instances: list["FakeSocket"] = [] + + def __init__(self, *args, **kwargs): + self.sent: list[tuple[bytes, tuple[str, int]]] = [] + self.inbox: list[tuple[bytes, tuple[str, int]]] = list(FakeSocket.queue) + self.timeout = None + self.closed = False + self.bound = None + FakeSocket.instances.append(self) + + queue: list[tuple[bytes, tuple[str, int]]] = [] + + def setsockopt(self, *args): + pass + + def settimeout(self, value): + self.timeout = value + + def bind(self, addr): + self.bound = addr + + def getsockname(self): + return self.bound or ("0.0.0.0", 0) + + def sendto(self, data, addr): + self.sent.append((bytes(data), addr)) + + def recvfrom(self, size): + if not self.inbox: + raise socket.timeout() + return self.inbox.pop(0) + + def close(self): + self.closed = True + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +@pytest.fixture +def fake_socket(monkeypatch): + FakeSocket.instances = [] + FakeSocket.queue = [] + monkeypatch.setattr(device_module.socket, "socket", FakeSocket) + monkeypatch.setattr(broadlink.socket, "socket", FakeSocket) + # Keep the retry loop from waiting on real time. + monkeypatch.setattr(device_module, "DEFAULT_RETRY_INTVL", 0.001) + return FakeSocket + + +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(fake_socket): + dev = fixed_device() + dev.id = 0x00000001 + payload = bytes([0x01]) + bytes(15) + fake_socket.queue = [(make_response(dev, bytes(16)), HOST)] + + resp = dev.send_packet(0x6A, payload) + + sock = fake_socket.instances[-1] + assert len(sock.sent) == 1 + frame, addr = sock.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(fake_socket): + dev = fixed_device() + fake_socket.queue = [(make_response(dev, b""), HOST)] + dev.send_packet(0x6A, bytes(20)) + frame = fake_socket.instances[-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(fake_socket): + dev = fixed_device() + dev.count = 0xFFFF + fake_socket.queue = [(make_response(dev, b""), HOST)] + dev.send_packet(0x6A, b"") + assert dev.count == 0x8000 + + +def test_send_packet_retries_then_times_out(fake_socket, monkeypatch): + dev = fixed_device() + dev.timeout = 0.01 + fake_socket.queue = [] # never answers + with pytest.raises(e.NetworkTimeoutError) as err: + dev.send_packet(0x6A, b"") + assert err.value.errno == -4000 + assert len(fake_socket.instances[-1].sent) >= 1 + + +def test_send_packet_rejects_short_response(fake_socket): + dev = fixed_device() + fake_socket.queue = [(bytes(0x10), HOST)] + with pytest.raises(e.DataValidationError) as err: + dev.send_packet(0x6A, b"") + assert err.value.errno == -4007 + + +def test_send_packet_rejects_bad_checksum(fake_socket): + dev = fixed_device() + frame = bytearray(make_response(dev, b"")) + frame[0x20] ^= 0xFF + fake_socket.queue = [(bytes(frame), HOST)] + with pytest.raises(e.DataValidationError) as err: + dev.send_packet(0x6A, b"") + assert err.value.errno == -4008 + + +# ------------------------------------------------------------------------- auth + + +def test_auth_uses_initial_key_and_installs_session_key(fake_socket): + 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) + fake_socket.queue = [(reply, HOST)] + + assert dev.auth() is True + + frame = fake_socket.instances[-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(fake_socket): + dev = fixed_device() + fake_socket.queue = [(make_response(dev, bytes(20), error=0xFFF9), HOST)] + with pytest.raises(e.AuthorizationError): + dev.auth() + + +# ------------------------------------------------------------------- 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) + + +def test_scan_builds_hello_packet_and_parses_replies(fake_socket): + fake_socket.queue = [ + (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, bytes.fromhex("34ea34000001"), "Plug", True), + ("192.0.2.11", 80)), + ] + found = list(device_module.scan(timeout=0.01, local_ip_address="192.0.2.2")) + + assert found == [ + (0x6026, ("192.0.2.10", 80), MAC, "Bedroom RM", False), + (0x2711, ("192.0.2.11", 80), bytes.fromhex("34ea34000001"), "Plug", True), + ] + sock = fake_socket.instances[-1] + assert sock.bound == ("192.0.2.2", 0) + packet, addr = sock.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] + body = bytearray(packet) + body[0x20:0x22] = b"\x00\x00" + assert packet[0x20:0x22] == (sum(body, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + assert sock.closed + + +def test_discover_and_hello_build_devices(fake_socket): + fake_socket.queue = [ + (hello_response(0x6026, MAC, "Bedroom RM", False), ("192.0.2.10", 80)), + ] + devices = broadlink.discover(timeout=0.01) + 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" + + fake_socket.queue = [ + (hello_response(0x6026, MAC, "Bedroom RM", True), ("192.0.2.10", 80)), + ] + dev = broadlink.hello("192.0.2.10", timeout=0.01) + assert dev.is_locked is True + assert fake_socket.instances[-1].sent[0][1] == ("192.0.2.10", 80) + + +def test_hello_times_out(fake_socket): + fake_socket.queue = [] + with pytest.raises(e.NetworkTimeoutError): + broadlink.hello("192.0.2.10", timeout=0.01) + + +def test_device_hello_validates_identity(fake_socket): + dev = fixed_device(broadlink.rm4pro, 0x6026) + fake_socket.queue = [(hello_response(0x6026, MAC, "Renamed", True), HOST)] + assert dev.hello() is True + assert dev.name == "Renamed" + assert dev.is_locked is True + + fake_socket.queue = [ + (hello_response(0x6026, bytes.fromhex("000000000001"), "Other", False), HOST) + ] + with pytest.raises(e.DataValidationError): + dev.hello() + + fake_socket.queue = [(hello_response(0x2711, MAC, "Other", False), HOST)] + with pytest.raises(e.DataValidationError): + dev.hello() + + +def test_ping_packet(fake_socket): + dev = fixed_device() + dev.ping() + packet, addr = fake_socket.instances[-1].sent[0] + assert addr == HOST + assert len(packet) == 0x30 + assert packet[0x26] == 1 + + +# ------------------------------------------------------------------ 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(fake_socket): + broadlink.setup("MyWifi", "hunter2", 3, ip_address="192.0.2.255") + packet, addr = fake_socket.instances[-1].sent[0] + assert addr == ("192.0.2.255", 80) + 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") + + +# ------------------------------------------------------------------ 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 From a2ae27ffc6c4ec2e35fa93d6208ef731f60aafe4 Mon Sep 17 00:00:00 2001 From: David <128871138+DAB-LABS@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:16:54 -0700 Subject: [PATCH 2/8] Make the library asynchronous (#2) Every method that reaches a device is now a coroutine, with the same names, arguments and return values as before. Discovery, hello and setup are coroutines and xdiscover is an async generator. The packet, CRC and datetime helpers stay synchronous. There is no synchronous compatibility layer. Transport: each device keeps one UDP endpoint (asyncio DatagramProtocol) for its lifetime and serializes requests on it with an asyncio.Lock; the previous code opened a socket per call and declared a lock it never acquired. Retry and timeout behaviour is unchanged. An expired session key is re-authenticated once and the request repeated. async with / aclose() release the endpoint. Device classes are a mechanical port (async def and await); the oracle suite recorded in the previous change passes unchanged, so every method sends the same bytes and decodes the same results as 0.19.0. Transport tests use a fake endpoint and gain cases for lock serialization, endpoint reuse, stale-reply draining and re-auth. The CLI runs under asyncio.run. README and CHANGELOG describe the break. Live-checked against an RM4 Pro: discovery, hello, auth, sensors, concurrent calls, learning primitives, send, and the timeout path. --- CHANGELOG.md | 20 +++ README.md | 106 ++++++++----- broadlink/__init__.py | 74 +++++---- broadlink/alarm.py | 4 +- broadlink/climate.py | 64 ++++---- broadlink/cover.py | 88 +++++------ broadlink/device.py | 312 ++++++++++++++++++++++++++----------- broadlink/hub.py | 12 +- broadlink/light.py | 16 +- broadlink/remote.py | 60 ++++---- broadlink/sensor.py | 16 +- broadlink/switch.py | 100 ++++++------ cli/broadlink_cli | 309 +++++++++++++++++++------------------ cli/broadlink_discovery | 41 +++-- tests/test_oracle.py | 2 +- tests/test_transport.py | 334 +++++++++++++++++++++++++++------------- 16 files changed, 941 insertions(+), 617 deletions(-) mode change 100755 => 100644 broadlink/climate.py mode change 100755 => 100644 cli/broadlink_cli mode change 100755 => 100644 cli/broadlink_discovery diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef4e048..9b44e43a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ history below starts at that fork point. ### Changed +- **The library is asynchronous.** Every method that talks to a device is + now a coroutine: `await device.auth()`, `await device.send_data(...)`, + `await device.check_sensors()`, and so on. Discovery is + `await broadlink.discover(...)`, `broadlink.hello(...)` and `setup(...)` + are coroutines, and `xdiscover(...)` is an async generator. The packet + helpers (`pulses_to_data`, `data_to_pulses`), CRC and datetime helpers + stay synchronous. There is no synchronous compatibility layer: a call + without `await` returns a coroutine and does nothing. +- Each device keeps one UDP endpoint for its lifetime (the previous + version opened a socket per call) and serializes requests on it with an + `asyncio.Lock`. The old code declared a lock but never acquired it. + `async with device:` or `await device.aclose()` releases the endpoint; + it reopens on the next call. +- When a device reports that the session key has expired, the library + re-authenticates once and repeats the request. Callers no longer need + their own re-auth loop. +- Retry and timeout behaviour is unchanged: a request is repeated every + second until `timeout` elapses, then `NetworkTimeoutError` is raised. +- `dooya.set_percentage_and_wait` sleeps with `asyncio.sleep`. +- The CLI tools run their body under `asyncio.run`. - Packaging moved to `pyproject.toml`; `setup.py` and the stale `requirements.txt` pin are gone. The distribution name is now `python-broadlink`; the import name stays `broadlink`. Python 3.13 or diff --git a/README.md b/README.md index 34a82386..a8babf31 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,30 @@ A Python module and CLI for controlling Broadlink devices locally. > RM Max and RM5 Plus. Version 1.0 will be asynchronous; see `CHANGELOG.md`. > Upstream's credit and MIT license are preserved. +## Version 1.0 is asynchronous + +Every call that reaches a device is a coroutine and must be awaited. This +is the whole change from the original library's API; method names, +arguments and return values are the same. + +```python +import asyncio +import broadlink + +async def main(): + devices = await broadlink.discover(timeout=5) + device = devices[0] + await device.auth() + print(await device.check_sensors()) + +asyncio.run(main()) +``` + +Calling a device method without `await` returns a coroutine object and +sends nothing; Python prints a `RuntimeWarning: coroutine ... was never +awaited` when it is garbage collected. If you need the old synchronous +behaviour, pin the original distribution (`broadlink==0.19.0`) instead. + The following devices are supported: - **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate @@ -42,11 +66,11 @@ environment, remove it first (`pip3 uninstall broadlink`); both provide the ## 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 ``` @@ -63,7 +87,7 @@ In order to control the device, you need to connect it to your local network. If - Manually connect to the WiFi SSID named BroadlinkProv. 2. Connect the device to your local network with the setup function. ```python3 -broadlink.setup('myssid', 'mynetworkpass', 3) +await broadlink.setup('myssid', 'mynetworkpass', 3) ``` Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2) @@ -72,7 +96,7 @@ Security mode options are (0 = none, 1 = WEP, 2 = WPA1, 3 = WPA2, 4 = WPA1/2) You may need to specify a broadcast address if setup is not working. ```python3 -broadlink.setup('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255') +await broadlink.setup('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255') ``` ### Discovery @@ -80,7 +104,7 @@ broadlink.setup('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255') Use this function to discover devices: ```python3 -devices = broadlink.discover() +devices = await broadlink.discover() ``` #### Advanced options @@ -88,29 +112,29 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery Using the IP address of your local machine: ```python3 -devices = broadlink.discover(local_ip_address='192.168.0.100') +devices = await broadlink.discover(local_ip_address='192.168.0.100') ``` Using the broadcast address of your subnet: ```python3 -devices = broadlink.discover(discover_ip_address='192.168.0.255') +devices = await broadlink.discover(discover_ip_address='192.168.0.255') ``` If the device is locked, it may not be discoverable with broadcast. In such cases, you can use the unicast version `broadlink.hello()` for direct discovery: ```python3 -device = broadlink.hello('192.168.0.16') +device = await broadlink.hello('192.168.0.16') ``` If you are a perfomance freak, use `broadlink.xdiscover()` to create devices instantly: ```python3 -for device in broadlink.xdiscover(): +async for device in broadlink.xdiscover(): print(device) # Example action. Do whatever you want here. ``` ### Authentication After discovering the device, call the `auth()` method to obtain the authentication key required for further communication: ```python3 -device.auth() +await device.auth() ``` The next steps depend on the type of device you want to control. @@ -123,12 +147,12 @@ Learning IR codes takes place in three steps. 1. Enter learning mode: ```python3 -device.enter_learning() +await device.enter_learning() ``` 2. When the LED blinks, point the remote at the Broadlink device and press the button you want to learn. 3. Get the IR packet. ```python3 -packet = device.check_data() +packet = await device.check_data() ``` ### Learning RF codes @@ -137,7 +161,7 @@ Learning RF codes takes place in six steps. 1. Sweep the frequency: ```python3 -device.sweep_frequency() +await device.sweep_frequency() ``` 2. When the LED blinks, point the remote at the Broadlink device for the first time and long press the button you want to learn. 3. Check if the frequency was successfully identified: @@ -148,12 +172,12 @@ if ok: ``` 4. Enter learning mode: ```python3 -device.find_rf_packet() +await device.find_rf_packet() ``` 5. When the LED blinks, point the remote at the Broadlink device for the second time and short press the button you want to learn. 6. Get the RF packet: ```python3 -packet = device.check_data() +packet = await device.check_data() ``` #### Notes @@ -164,25 +188,25 @@ 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() ``` ### 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 @@ -199,8 +223,8 @@ state = device.get_energy() ### Setting power state ```python3 -device.set_power(1, True) # Example socket. It could be 2 or 3. -device.set_power(1, False) +await device.set_power(1, True) # Example socket. It could be 2 or 3. +await device.set_power(1, False) ``` ### Checking power state @@ -217,35 +241,35 @@ state = device.get_state() ### Setting state attributes ```python3 -devices[0].set_state(pwr=0) -devices[0].set_state(pwr=1) -devices[0].set_state(brightness=75) -devices[0].set_state(bulb_colormode=0) -devices[0].set_state(blue=255) -devices[0].set_state(red=0) -devices[0].set_state(green=128) -devices[0].set_state(bulb_colormode=1) +await devices[0].set_state(pwr=0) +await devices[0].set_state(pwr=1) +await devices[0].set_state(brightness=75) +await devices[0].set_state(bulb_colormode=0) +await devices[0].set_state(blue=255) +await devices[0].set_state(red=0) +await devices[0].set_state(green=128) +await devices[0].set_state(bulb_colormode=1) ``` ## Environment sensors ### Fetching sensor data ```python3 -data = device.check_sensors() +data = await device.check_sensors() ``` ## Hubs ### Discovering subdevices ```python3 -device.get_subdevices() +await device.get_subdevices() ``` ### Fetching data Use the DID obtained from get_subdevices() for the input parameter to query specific sub-device. ```python3 -device.get_state(did="00000000000000000000a043b0d06963") +await device.get_state(did="00000000000000000000a043b0d06963") ``` ### Setting state attributes @@ -253,13 +277,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 b2fa3d9a..bd77fa2a 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 """The python-broadlink library.""" -import socket -from typing import Generator, List, Optional, Tuple, Union +from collections.abc import AsyncIterator +from typing import List, Optional, Tuple, Union from . import exceptions as e from .alarm import S1C from .climate import hvac, hysen from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT from .cover import dooya, dooya2, wser -from .device import Device, ping, scan +from .device import Device, _open_endpoint, ping, scan from .hub import s3 from .light import lb1, lb2 from .remote import rm, rm4, rm4mini, rm4pro, rmmini, rmminib, rmpro @@ -238,64 +238,62 @@ def gendevice( return Device(host, mac, dev_type, name=name, is_locked=is_locked) -def hello( +async def hello( ip_address: str, port: int = DEFAULT_PORT, - timeout: int = DEFAULT_TIMEOUT, + timeout: float = DEFAULT_TIMEOUT, ) -> Device: """Direct device discovery. Useful if the device is locked. """ - try: - return next( - xdiscover( - timeout=timeout, - discover_ip_address=ip_address, - discover_ip_port=port, - ) - ) - except StopIteration as err: - raise e.NetworkTimeoutError( - -4000, - "Network timeout", - f"No response received within {timeout}s", - ) from err + async for device in xdiscover( + timeout=timeout, + discover_ip_address=ip_address, + discover_ip_port=port, + ): + return device + raise e.NetworkTimeoutError( + -4000, + "Network timeout", + f"No response received within {timeout}s", + ) -def discover( - timeout: int = DEFAULT_TIMEOUT, +async def discover( + timeout: float = DEFAULT_TIMEOUT, local_ip_address: Optional[str] = None, discover_ip_address: str = DEFAULT_BCAST_ADDR, discover_ip_port: int = DEFAULT_PORT, ) -> List[Device]: """Discover devices connected to the local network.""" - responses = scan( - timeout, local_ip_address, discover_ip_address, discover_ip_port - ) - return [gendevice(*resp) for resp in responses] + return [ + device + async for device in xdiscover( + timeout, local_ip_address, discover_ip_address, discover_ip_port + ) + ] -def xdiscover( - timeout: int = DEFAULT_TIMEOUT, +async def xdiscover( + timeout: float = DEFAULT_TIMEOUT, local_ip_address: Optional[str] = None, discover_ip_address: str = DEFAULT_BCAST_ADDR, discover_ip_port: int = DEFAULT_PORT, -) -> Generator[Device, None, None]: +) -> AsyncIterator[Device]: """Discover devices connected to the local network. - This function returns a generator that yields devices instantly. + Yields each device as soon as it answers. """ - responses = scan( + async for resp in scan( timeout, local_ip_address, discover_ip_address, discover_ip_port - ) - for resp in responses: + ): yield gendevice(*resp) # Setup a new Broadlink device via AP Mode. Review the README to see how to enter AP Mode. # Only tested with Broadlink RM3 Mini (Blackbean) -def setup( +async def setup( ssid: str, password: str, security_mode: int, @@ -326,8 +324,8 @@ def setup( payload[0x20] = checksum & 0xFF # Checksum 1 position payload[0x21] = checksum >> 8 # Checksum 2 position - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Internet # UDP - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.sendto(payload, (ip_address, DEFAULT_PORT)) - sock.close() + transport, _ = await _open_endpoint(broadcast=True) + try: + transport.sendto(payload, (ip_address, DEFAULT_PORT)) + finally: + transport.close() diff --git a/broadlink/alarm.py b/broadlink/alarm.py index a9b5e879..2c3358de 100644 --- a/broadlink/alarm.py +++ b/broadlink/alarm.py @@ -14,11 +14,11 @@ class S1C(Device): 0x21: "Motion Sensor", } - def get_sensors_status(self) -> dict: + async def get_sensors_status(self) -> dict: """Return the state of the sensors.""" packet = bytearray(16) packet[0] = 0x06 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) count = payload[0x4] diff --git a/broadlink/climate.py b/broadlink/climate.py old mode 100755 new mode 100644 index 1a0c6006..5d75457d --- a/broadlink/climate.py +++ b/broadlink/climate.py @@ -21,14 +21,14 @@ class hysen(Device): TYPE = "HYS" - def send_request(self, request: Sequence[int]) -> bytes: + async def send_request(self, request: Sequence[int]) -> bytes: """Send a request to the device.""" packet = bytearray() packet.extend((len(request) + 2).to_bytes(2, "little")) packet.extend(request) packet.extend(CRC16.calculate(request).to_bytes(2, "little")) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) @@ -52,22 +52,22 @@ def _decode_temp(self, payload, base_index): offset = (offset_raw_value + 1) / 10 if add_offset else 0.0 return base_temp + offset - def get_temp(self) -> float: + async def get_temp(self) -> float: """Return the room temperature in degrees celsius.""" - payload = self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x08]) + payload = await self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x08]) return self._decode_temp(payload, 5) - def get_external_temp(self) -> float: + async def get_external_temp(self) -> float: """Return the external temperature in degrees celsius.""" - payload = self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x08]) + payload = await self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x08]) return self._decode_temp(payload, 18) - def get_full_status(self) -> dict: + async def get_full_status(self) -> dict: """Return the state of the device. Timer schedule included. """ - payload = self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x16]) + payload = await self.send_request([0x01, 0x03, 0x00, 0x00, 0x00, 0x16]) data = {} data["remote_lock"] = payload[3] & 1 data["power"] = payload[4] & 1 @@ -127,12 +127,12 @@ def get_full_status(self) -> dict: # E.g. loop_mode = 0 ("12345,67") means Saturday and Sunday (weekend schedule) # loop_mode = 2 ("1234567") means every day, including Saturday and Sunday (weekday schedule) # The sensor command is currently experimental - def set_mode( + async def set_mode( self, auto_mode: int, loop_mode: int, sensor: int = 0 ) -> None: """Set the mode of the device.""" mode_byte = ((loop_mode + 1) << 4) + auto_mode - self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor]) + await self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor]) # Advanced settings # Sensor mode (SEN) sensor = 0 for internal sensor, 1 for external sensor, @@ -145,7 +145,7 @@ def set_mode( # Anti-freezing function (FrE) fre = 0 for anti-freezing function shut down, # 1 for anti-freezing function open. Factory default: 0 # Power on memory (POn) poweron = 0 for off, 1 for on. Default: 0 - def set_advanced( + async def set_advanced( self, loop_mode: int, sensor: int, @@ -158,7 +158,7 @@ def set_advanced( poweron: int, ) -> None: """Set advanced options.""" - self.send_request( + await self.send_request( [ 0x01, 0x10, @@ -182,34 +182,34 @@ def set_advanced( # For backwards compatibility only. Prefer calling set_mode directly. # Note this function invokes loop_mode=0 and sensor=0. - def switch_to_auto(self) -> None: + async def switch_to_auto(self) -> None: """Switch mode to auto.""" - self.set_mode(auto_mode=1, loop_mode=0) + await self.set_mode(auto_mode=1, loop_mode=0) - def switch_to_manual(self) -> None: + async def switch_to_manual(self) -> None: """Switch mode to manual.""" - self.set_mode(auto_mode=0, loop_mode=0) + await self.set_mode(auto_mode=0, loop_mode=0) # Set temperature for manual mode (also activates manual mode if currently in automatic) - def set_temp(self, temp: float) -> None: + async def set_temp(self, temp: float) -> None: """Set the target temperature.""" - self.send_request([0x01, 0x06, 0x00, 0x01, 0x00, int(temp * 2)]) + await self.send_request([0x01, 0x06, 0x00, 0x01, 0x00, int(temp * 2)]) # Set device on(1) or off(0), does not deactivate Wifi connectivity. # Remote lock disables control by buttons on thermostat. # heating_cooling: heating(0) cooling(1) - def set_power( + async def set_power( self, power: int = 1, remote_lock: int = 0, heating_cooling: int = 0 ) -> None: """Set the power state of the device.""" state = (heating_cooling << 7) + power - self.send_request([0x01, 0x06, 0x00, 0x00, remote_lock, state]) + await self.send_request([0x01, 0x06, 0x00, 0x00, remote_lock, state]) # set time on device # n.b. day=1 is Monday, ..., day=7 is Sunday - def set_time(self, hour: int, minute: int, second: int, day: int) -> None: + async def set_time(self, hour: int, minute: int, second: int, day: int) -> None: """Set the time.""" - self.send_request( + await self.send_request( [ 0x01, 0x10, @@ -231,7 +231,7 @@ def set_time(self, hour: int, minute: int, second: int, day: int) -> None: # {'start_hour':17, 'start_minute':30, 'temp': 22 } # Each one specifies the thermostat temp that will become effective at start_hour:start_minute # weekend is similar but only has 2 (e.g. switch on in morning and off in afternoon) - def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None: + async def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None: """Set timer schedule.""" request = [0x01, 0x10, 0x00, 0x0A, 0x00, 0x0C, 0x18] @@ -253,7 +253,7 @@ def set_schedule(self, weekday: List[dict], weekend: List[dict]) -> None: for i in range(0, 2): request.append(int(weekend[i]["temp"] * 2)) - self.send_request(request) + await self.send_request(request) class hvac(Device): @@ -343,11 +343,11 @@ def _decode(self, response: bytes) -> bytes: d_len = int.from_bytes(payload[0x08:0x0A], "little") return payload[0x0A:0x0A+d_len] - def _send(self, command: int, data: bytes = b"") -> bytes: + async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a command to the unit.""" prefix = bytes([((command << 4) | 1), 1]) packet = self._encode(prefix + data) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response)[0x02:] @@ -369,7 +369,7 @@ def _parse_state(self, data: bytes) -> dict: state["mildew"] = bool(data[0x0A] & 1 << 3) return state - def set_state( + async def set_state( self, power: bool, target_temp: float, # 16<=target_temp<=32 @@ -414,10 +414,10 @@ def set_state( data[0x0A] = display << 4 | mildew << 3 data[0x0C] = UNK2 - resp = self._send(0, data) + resp = await self._send(0, data) return self._parse_state(resp) - def get_state(self) -> dict: + async def get_state(self) -> dict: """Returns a dictionary with the unit's parameters. Returns: @@ -436,7 +436,7 @@ def get_state(self) -> dict: clean (bool): mildew (bool): """ - resp = self._send(1) + resp = await self._send(1) if len(resp) < 13: raise e.DataValidationError( @@ -447,7 +447,7 @@ def get_state(self) -> dict: return self._parse_state(resp) - def get_ac_info(self) -> dict: + async def get_ac_info(self) -> dict: """Returns dictionary with AC info. Returns: @@ -455,7 +455,7 @@ def get_ac_info(self) -> dict: power (bool): power ambient_temp (float): ambient temperature """ - resp = self._send(2) + resp = await self._send(2) if len(resp) < 22: raise e.DataValidationError( diff --git a/broadlink/cover.py b/broadlink/cover.py index 75317943..0319457a 100644 --- a/broadlink/cover.py +++ b/broadlink/cover.py @@ -1,5 +1,5 @@ """Support for covers.""" -import time +import asyncio from typing import Sequence from . import exceptions as e @@ -11,7 +11,7 @@ class dooya(Device): TYPE = "DT360E" - def _send(self, command: int, attribute: int = 0) -> int: + async def _send(self, command: int, attribute: int = 0) -> int: """Send a packet to the device.""" packet = bytearray(16) packet[0x00] = 0x09 @@ -21,42 +21,42 @@ def _send(self, command: int, attribute: int = 0) -> int: packet[0x09] = 0xFA packet[0x0A] = 0x44 - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) return payload[4] - def open(self) -> int: + async def open(self) -> int: """Open the curtain.""" - return self._send(0x01) + return await self._send(0x01) - def close(self) -> int: + async def close(self) -> int: """Close the curtain.""" - return self._send(0x02) + return await self._send(0x02) - def stop(self) -> int: + async def stop(self) -> int: """Stop the curtain.""" - return self._send(0x03) + return await self._send(0x03) - def get_percentage(self) -> int: + async def get_percentage(self) -> int: """Return the position of the curtain.""" - return self._send(0x06, 0x5D) + return await self._send(0x06, 0x5D) - def set_percentage_and_wait(self, new_percentage: int) -> None: + async def set_percentage_and_wait(self, new_percentage: int) -> None: """Set the position of the curtain.""" - current = self.get_percentage() + current = await self.get_percentage() if current > new_percentage: - self.close() + await self.close() while current is not None and current > new_percentage: - time.sleep(0.2) - current = self.get_percentage() + await asyncio.sleep(0.2) + current = await self.get_percentage() elif current < new_percentage: - self.open() + await self.open() while current is not None and current < new_percentage: - time.sleep(0.2) - current = self.get_percentage() - self.stop() + await asyncio.sleep(0.2) + current = await self.get_percentage() + await self.stop() class dooya2(Device): @@ -64,7 +64,7 @@ class dooya2(Device): TYPE = "DT360E-2" - def _send(self, operation: int, data: Sequence = b""): + async def _send(self, operation: int, data: Sequence = b""): """Send a command to the device.""" packet = bytearray(12) packet[0x02] = 0xA5 @@ -89,31 +89,31 @@ def _send(self, operation: int, data: Sequence = b""): packet[0x00] = packet_len & 0xFF packet[0x01] = packet_len >> 8 - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) return payload - def open(self) -> None: + async def open(self) -> None: """Open the curtain.""" - self._send(2, [0x00, 0x01, 0x00]) + await self._send(2, [0x00, 0x01, 0x00]) - def close(self) -> None: + async def close(self) -> None: """Close the curtain.""" - self._send(2, [0x00, 0x02, 0x00]) + await self._send(2, [0x00, 0x02, 0x00]) - def stop(self) -> None: + async def stop(self) -> None: """Stop the curtain.""" - self._send(2, [0x00, 0x03, 0x00]) + await self._send(2, [0x00, 0x03, 0x00]) - def get_percentage(self) -> int: + async def get_percentage(self) -> int: """Return the position of the curtain.""" - resp = self._send(1, [0x00, 0x06, 0x00]) + resp = await self._send(1, [0x00, 0x06, 0x00]) return resp[0x11] - def set_percentage(self, new_percentage: int) -> None: + async def set_percentage(self, new_percentage: int) -> None: """Set the position of the curtain.""" - self._send(2, [0x00, 0x09, new_percentage]) + await self._send(2, [0x00, 0x09, new_percentage]) class wser(Device): @@ -121,7 +121,7 @@ class wser(Device): TYPE = "WSER" - def _send(self, operation: int, data: Sequence = b""): + async def _send(self, operation: int, data: Sequence = b""): """Send a command to the device.""" packet = bytearray(12) packet[0x02] = 0xA5 @@ -146,37 +146,37 @@ def _send(self, operation: int, data: Sequence = b""): packet[0x00] = packet_len & 0xFF packet[0x01] = packet_len >> 8 - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) return payload - def get_position(self) -> int: + async def get_position(self) -> int: """Return the position of the curtain.""" - resp = self._send(1, []) + resp = await self._send(1, []) position = resp[0x0E] return position - def open(self) -> int: + async def open(self) -> int: """Open the curtain.""" - resp = self._send(2, [0x4A, 0x31, 0xA0]) + resp = await self._send(2, [0x4A, 0x31, 0xA0]) position = resp[0x0E] return position - def close(self) -> int: + async def close(self) -> int: """Close the curtain.""" - resp = self._send(2, [0x61, 0x32, 0xA0]) + resp = await self._send(2, [0x61, 0x32, 0xA0]) position = resp[0x0E] return position - def stop(self) -> int: + async def stop(self) -> int: """Stop the curtain.""" - resp = self._send(2, [0x4C, 0x73, 0xA0]) + resp = await self._send(2, [0x4C, 0x73, 0xA0]) position = resp[0x0E] return position - def set_position(self, position: int) -> int: + async def set_position(self, position: int) -> int: """Set the position of the curtain.""" - resp = self._send(2, [position, 0x70, 0xA0]) + resp = await self._send(2, [position, 0x70, 0xA0]) position = resp[0x0E] return position diff --git a/broadlink/device.py b/broadlink/device.py index 22c3ebed..2dc95e0d 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -1,9 +1,19 @@ -"""Support for Broadlink devices.""" +"""Support for Broadlink devices. + +Transport layer. Every device method ends up in :meth:`Device.send_packet`, +which frames, encrypts and sends one request over UDP and waits for the one +reply. The protocol is strictly request and reply and the device never +speaks unprompted, so each device keeps a single datagram endpoint and an +``asyncio.Lock`` that serializes calls on it. +""" + +from __future__ import annotations + +import asyncio import random import socket -import threading -import time -from typing import Generator, Optional, Tuple, Union +from collections.abc import AsyncIterator +from typing import Optional, Tuple, Union from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -17,77 +27,141 @@ ) from .protocol import Datetime -HelloResponse = Tuple[int, Tuple[str, int], str, str, bool] +HelloResponse = Tuple[int, Tuple[str, int], bytes, str, bool] +# Device error codes that mean the session key is no longer accepted and a +# fresh auth() will fix it. -7: control key expired; -4012: control id error. +_REAUTH_CODES = {-7, -4012} -def scan( - timeout: int = DEFAULT_TIMEOUT, - local_ip_address: Optional[str] = None, - discover_ip_address: str = DEFAULT_BCAST_ADDR, - discover_ip_port: int = DEFAULT_PORT, -) -> Generator[HelloResponse, None, None]: - """Broadcast a hello message and yield responses.""" - conn = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - conn.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - conn.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - - if local_ip_address: - conn.bind((local_ip_address, 0)) - port = conn.getsockname()[1] - else: - local_ip_address = "0.0.0.0" - port = 0 +class _Protocol(asyncio.DatagramProtocol): + """Datagram protocol that hands every received packet to a queue.""" + + def __init__(self) -> None: + self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue() + self.transport: Optional[asyncio.DatagramTransport] = None + self.closed = asyncio.get_running_loop().create_future() + + def connection_made(self, transport) -> None: # type: ignore[override] + self.transport = transport + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + self.queue.put_nowait((data, addr)) + + def error_received(self, exc: Exception) -> None: + # ICMP unreachable and the like. Surface it as a receive of nothing; + # the retry loop will time out and raise NetworkTimeoutError. + pass + + def connection_lost(self, exc: Optional[Exception]) -> None: + if not self.closed.done(): + self.closed.set_result(None) + + def drain(self) -> None: + """Drop anything that arrived before the current request.""" + while not self.queue.empty(): + self.queue.get_nowait() + + +async def _open_endpoint( + local_addr: Optional[tuple[str, int]] = None, + remote_addr: Optional[tuple[str, int]] = None, + broadcast: bool = False, +) -> tuple[asyncio.DatagramTransport, _Protocol]: + """Create a UDP endpoint. Tests replace this to fake the network.""" + loop = asyncio.get_running_loop() + transport, protocol = await loop.create_datagram_endpoint( + _Protocol, + local_addr=local_addr, + remote_addr=remote_addr, + family=socket.AF_INET, + allow_broadcast=broadcast, + ) + return transport, protocol # type: ignore[return-value] + + +def _hello_packet(local_ip_address: str, port: int) -> bytearray: packet = bytearray(0x30) packet[0x08:0x14] = Datetime.pack(Datetime.now()) packet[0x18:0x1C] = socket.inet_aton(local_ip_address)[::-1] packet[0x1C:0x1E] = port.to_bytes(2, "little") packet[0x26] = 6 - checksum = sum(packet, 0xBEAF) & 0xFFFF packet[0x20:0x22] = checksum.to_bytes(2, "little") + return packet - start_time = time.time() - discovered = [] - try: - while (time.time() - start_time) < timeout: - time_left = timeout - (time.time() - start_time) - conn.settimeout(min(DEFAULT_RETRY_INTVL, time_left)) - conn.sendto(packet, (discover_ip_address, discover_ip_port)) +def _parse_hello(resp: bytes, host: tuple[str, int]) -> HelloResponse: + devtype = resp[0x34] | resp[0x35] << 8 + mac = resp[0x3A:0x40][::-1] + name = resp[0x40:].split(b"\x00")[0].decode() + is_locked = bool(resp[0x7F]) + return devtype, host, mac, name, is_locked + +async def scan( + timeout: float = DEFAULT_TIMEOUT, + local_ip_address: Optional[str] = None, + discover_ip_address: str = DEFAULT_BCAST_ADDR, + discover_ip_port: int = DEFAULT_PORT, +) -> AsyncIterator[HelloResponse]: + """Broadcast a hello message and yield responses as they arrive. + + The hello is repeated every ``DEFAULT_RETRY_INTVL`` seconds until + ``timeout`` elapses. Each device is yielded once. + """ + local_addr = (local_ip_address, 0) if local_ip_address else None + transport, protocol = await _open_endpoint(local_addr=local_addr, broadcast=True) + try: + if local_ip_address: + port = transport.get_extra_info("sockname")[1] + else: + local_ip_address = "0.0.0.0" + port = 0 + packet = _hello_packet(local_ip_address, port) + + loop = asyncio.get_running_loop() + start = loop.time() + discovered: set[tuple[tuple[str, int], bytes, int]] = set() + + while (loop.time() - start) < timeout: + transport.sendto(packet, (discover_ip_address, discover_ip_port)) + deadline = min(DEFAULT_RETRY_INTVL, timeout - (loop.time() - start)) + slot_end = loop.time() + deadline while True: + remaining = slot_end - loop.time() + if remaining <= 0: + break try: - resp, host = conn.recvfrom(1024) - except socket.timeout: + resp, host = await asyncio.wait_for(protocol.queue.get(), remaining) + except asyncio.TimeoutError: break - - devtype = resp[0x34] | resp[0x35] << 8 - mac = resp[0x3A:0x40][::-1] - - if (host, mac, devtype) in discovered: + if len(resp) < 0x80: continue - discovered.append((host, mac, devtype)) - - name = resp[0x40:].split(b"\x00")[0].decode() - is_locked = bool(resp[0x7F]) - yield devtype, host, mac, name, is_locked + entry = _parse_hello(resp, host) + key = (entry[1], entry[2], entry[0]) + if key in discovered: + continue + discovered.add(key) + yield entry finally: - conn.close() + transport.close() -def ping(ip_address: str, port: int = DEFAULT_PORT) -> None: +async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None: """Send a ping packet to an address. This packet feeds the watchdog timer of firmwares >= v53. Useful to prevent reboots when the cloud cannot be reached. It must be sent every 2 minutes in such cases. """ - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as conn: - conn.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + transport, _ = await _open_endpoint(broadcast=True) + try: packet = bytearray(0x30) packet[0x26] = 1 - conn.sendto(packet, (ip_address, port)) + transport.sendto(packet, (ip_address, port)) + finally: + transport.close() class Device: @@ -103,7 +177,7 @@ def __init__( host: Tuple[str, int], mac: Union[bytes, str], devtype: int, - timeout: int = DEFAULT_TIMEOUT, + timeout: float = DEFAULT_TIMEOUT, name: str = "", model: str = "", manufacturer: str = "", @@ -122,11 +196,15 @@ def __init__( self.iv = bytes.fromhex(self.__INIT_VECT) self.id = 0 self.type = self.TYPE # For backwards compatibility. - self.lock = threading.Lock() self.aes = None self.update_aes(bytes.fromhex(self.__INIT_KEY)) + self._lock: Optional[asyncio.Lock] = None + self._transport: Optional[asyncio.DatagramTransport] = None + self._protocol: Optional[_Protocol] = None + self._reauth_ok = True + def __repr__(self) -> str: """Return a formal representation of the device.""" return ( @@ -154,6 +232,14 @@ def __str__(self) -> str: ":".join(format(x, "02X") for x in self.mac), ) + async def __aenter__(self) -> "Device": + return self + + async def __aexit__(self, *exc) -> None: + await self.aclose() + + # ------------------------------------------------------------ crypto + def update_aes(self, key: bytes) -> None: """Update AES.""" self.aes = Cipher( @@ -170,7 +256,9 @@ def decrypt(self, payload: bytes) -> bytes: decryptor = self.aes.decryptor() return decryptor.update(bytes(payload)) + decryptor.finalize() - def auth(self) -> bool: + # ---------------------------------------------------------- session + + async def auth(self) -> bool: """Authenticate to the device.""" self.id = 0 self.update_aes(bytes.fromhex(self.__INIT_KEY)) @@ -181,7 +269,7 @@ def auth(self) -> bool: packet[0x2D] = 0x01 packet[0x30:0x36] = "Test 1".encode() - response = self.send_packet(0x65, packet) + response = await self.send_packet(0x65, packet, _reauth=False) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) @@ -189,7 +277,7 @@ def auth(self) -> bool: self.update_aes(payload[0x04:0x14]) return True - def hello(self, local_ip_address=None) -> bool: + async def hello(self, local_ip_address=None) -> bool: """Send a hello message to the device. Device information is checked before updating name and lock status. @@ -200,15 +288,16 @@ def hello(self, local_ip_address=None) -> bool: discover_ip_address=self.host[0], discover_ip_port=self.host[1], ) - try: - devtype, _, mac, name, is_locked = next(responses) - - except StopIteration as err: + entry = None + async for entry in responses: + break + if entry is None: raise e.NetworkTimeoutError( -4000, "Network timeout", f"No response received within {self.timeout}s", - ) from err + ) + devtype, _, mac, name, is_locked = entry if mac != self.mac: raise e.DataValidationError( @@ -230,40 +319,40 @@ def hello(self, local_ip_address=None) -> bool: self.is_locked = is_locked return True - def ping(self) -> None: + async def ping(self) -> None: """Ping the device. This packet feeds the watchdog timer of firmwares >= v53. Useful to prevent reboots when the cloud cannot be reached. It must be sent every 2 minutes in such cases. """ - ping(self.host[0], port=self.host[1]) + await ping(self.host[0], port=self.host[1]) - def get_fwversion(self) -> int: + async def get_fwversion(self) -> int: """Get firmware version.""" packet = bytearray([0x68]) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return payload[0x4] | payload[0x5] << 8 - def set_name(self, name: str) -> None: + async def set_name(self, name: str) -> None: """Set device name.""" packet = bytearray(4) packet += name.encode("utf-8") packet += bytearray(0x50 - len(packet)) packet[0x43] = self.is_locked - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) self.name = name - def set_lock(self, state: bool) -> None: + async def set_lock(self, state: bool) -> None: """Lock/unlock the device.""" packet = bytearray(4) packet += self.name.encode("utf-8") packet += bytearray(0x50 - len(packet)) packet[0x43] = bool(state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) self.is_locked = bool(state) @@ -271,8 +360,24 @@ def get_type(self) -> str: """Return device type.""" return self.type - def send_packet(self, packet_type: int, payload: bytes) -> bytes: - """Send a packet to the device.""" + # -------------------------------------------------------- transport + + async def aclose(self) -> None: + """Close the device's endpoint. It is reopened on the next call.""" + if self._transport is not None: + self._transport.close() + self._transport = None + self._protocol = None + + async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]: + if self._transport is None or self._transport.is_closing(): + self._transport, self._protocol = await _open_endpoint( + remote_addr=self.host + ) + return self._transport, self._protocol # type: ignore[return-value] + + def _frame(self, packet_type: int, payload: bytes) -> bytes: + """Build the wire frame for one request (advances the counter).""" self.count = ((self.count + 1) | 0x8000) & 0xFFFF packet = bytearray(0x38) packet[0x00:0x08] = bytes.fromhex("5aa5aa555aa5aa55") @@ -291,27 +396,10 @@ def send_packet(self, packet_type: int, payload: bytes) -> bytes: checksum = sum(packet, 0xBEAF) & 0xFFFF packet[0x20:0x22] = checksum.to_bytes(2, "little") + return bytes(packet) - with self.lock and socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as conn: - timeout = self.timeout - start_time = time.time() - - while True: - time_left = timeout - (time.time() - start_time) - conn.settimeout(min(DEFAULT_RETRY_INTVL, time_left)) - conn.sendto(packet, self.host) - - try: - resp = conn.recvfrom(2048)[0] - break - except socket.timeout as err: - if (time.time() - start_time) > timeout: - raise e.NetworkTimeoutError( - -4000, - "Network timeout", - f"No response received within {timeout}s", - ) from err - + @staticmethod + def _validate(resp: bytes) -> bytes: if len(resp) < 0x30: raise e.DataValidationError( -4007, @@ -328,5 +416,55 @@ def send_packet(self, packet_type: int, payload: bytes) -> bytes: "Received data packet check error", f"Expected a checksum of {nom_checksum} and received {real_checksum}", ) + return resp + async def _exchange(self, packet: bytes) -> bytes: + """Send one frame and wait for one reply, resending on silence.""" + transport, protocol = await self._endpoint() + protocol.drain() + loop = asyncio.get_running_loop() + start = loop.time() + timeout = self.timeout + + while True: + transport.sendto(packet) + time_left = timeout - (loop.time() - start) + wait = min(DEFAULT_RETRY_INTVL, time_left) + try: + resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0)) + except asyncio.TimeoutError: + if (loop.time() - start) >= timeout: + raise e.NetworkTimeoutError( + -4000, + "Network timeout", + f"No response received within {timeout}s", + ) from None + continue + return self._validate(resp) + + async def send_packet( + self, packet_type: int, payload: bytes, *, _reauth: bool = True + ) -> bytes: + """Send a packet to the device and return the raw response frame. + + If the device answers that the session key is no longer valid, the + session is re-authenticated once and the request is sent again. + """ + if self._lock is None: + self._lock = asyncio.Lock() + async with self._lock: + resp = await self._exchange(self._frame(packet_type, bytes(payload))) + + if _reauth and self._reauth_ok: + code = int.from_bytes(resp[0x22:0x24], "little", signed=True) + if code in _REAUTH_CODES: + self._reauth_ok = False + try: + await self.auth() + async with self._lock: + resp = await self._exchange( + self._frame(packet_type, bytes(payload)) + ) + finally: + self._reauth_ok = True return resp diff --git a/broadlink/hub.py b/broadlink/hub.py index 40dd8e2d..1d74041f 100644 --- a/broadlink/hub.py +++ b/broadlink/hub.py @@ -13,7 +13,7 @@ class s3(Device): TYPE = "S3" MAX_SUBDEVICES = 8 - def get_subdevices(self, step: int = 5) -> list: + async def get_subdevices(self, step: int = 5) -> list: """Return a list of sub devices.""" total = self.MAX_SUBDEVICES sub_devices = [] @@ -23,7 +23,7 @@ def get_subdevices(self, step: int = 5) -> list: while index < total: state = {"count": step, "index": index} packet = self._encode(14, state) - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) resp = self._decode(resp) @@ -43,18 +43,18 @@ def get_subdevices(self, step: int = 5) -> list: return sub_devices - def get_state(self, did: Optional[str] = None) -> dict: + async def get_state(self, did: Optional[str] = None) -> dict: """Return the power state of the device.""" state = {} if did is not None: state["did"] = did packet = self._encode(1, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) - def set_state( + async def set_state( self, did: Optional[str] = None, pwr1: Optional[bool] = None, @@ -73,7 +73,7 @@ def set_state( state["pwr3"] = int(bool(pwr3)) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) diff --git a/broadlink/light.py b/broadlink/light.py index 1ae87e8f..6225887e 100644 --- a/broadlink/light.py +++ b/broadlink/light.py @@ -21,17 +21,17 @@ class ColorMode(enum.IntEnum): WHITE = 1 SCENE = 2 - def get_state(self) -> dict: + async def get_state(self) -> dict: """Return the power state of the device. Example: `{'red': 128, 'blue': 255, 'green': 128, 'pwr': 1, 'brightness': 75, 'colortemp': 2700, 'hue': 240, 'saturation': 50, 'transitionduration': 1500, 'maxworktime': 0, 'bulb_colormode': 1, 'bulb_scenes': '["@01686464,0,0,0", "#ffffff,10,0,#000000,190,0,0", "2700+100,0,0,0", "#ff0000,500,2500,#00FF00,500,2500,#0000FF,500,2500,0", "@01686464,100,2400,@01686401,100,2400,0", "@01686464,100,2400,@01686401,100,2400,@005a6464,100,2400,@005a6401,100,2400,0", "@01686464,10,0,@00000000,190,0,0", "@01686464,200,0,@005a6464,200,0,0"]', 'bulb_scene': '', 'bulb_sceneidx': 255}` """ packet = self._encode(1, {}) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) - def set_state( + async def set_state( self, pwr: Optional[bool] = None, red: Optional[int] = None, @@ -80,7 +80,7 @@ def set_state( state["bulb_sceneidx"] = int(bulb_sceneidx) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) @@ -119,17 +119,17 @@ class ColorMode(enum.IntEnum): WHITE = 1 SCENE = 2 - def get_state(self) -> dict: + async def get_state(self) -> dict: """Return the power state of the device. Example: `{'red': 128, 'blue': 255, 'green': 128, 'pwr': 1, 'brightness': 75, 'colortemp': 2700, 'hue': 240, 'saturation': 50, 'transitionduration': 1500, 'maxworktime': 0, 'bulb_colormode': 1, 'bulb_scenes': '["@01686464,0,0,0", "#ffffff,10,0,#000000,190,0,0", "2700+100,0,0,0", "#ff0000,500,2500,#00FF00,500,2500,#0000FF,500,2500,0", "@01686464,100,2400,@01686401,100,2400,0", "@01686464,100,2400,@01686401,100,2400,@005a6464,100,2400,@005a6401,100,2400,0", "@01686464,10,0,@00000000,190,0,0", "@01686464,200,0,@005a6464,200,0,0"]', 'bulb_scene': ''}` """ packet = self._encode(1, {}) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) - def set_state( + async def set_state( self, pwr: Optional[bool] = None, red: Optional[int] = None, @@ -175,7 +175,7 @@ def set_state( state["bulb_scene"] = str(bulb_scene) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) diff --git a/broadlink/remote.py b/broadlink/remote.py index 60c54ce2..64103882 100644 --- a/broadlink/remote.py +++ b/broadlink/remote.py @@ -52,31 +52,31 @@ class rmmini(Device): TYPE = "RMMINI" - 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(" 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) + 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) class rmpro(rmmini): @@ -84,37 +84,37 @@ class rmpro(rmmini): TYPE = "RMPRO" - def sweep_frequency(self) -> None: + async def sweep_frequency(self) -> None: """Sweep frequency.""" - self._send(0x19) + await self._send(0x19) - def check_frequency(self) -> Tuple[bool, float]: + async def check_frequency(self) -> Tuple[bool, float]: """Return True if the frequency was identified successfully.""" - resp = self._send(0x1A) + resp = await self._send(0x1A) is_found = bool(resp[0]) frequency = struct.unpack(" None: + async def find_rf_packet(self, frequency: Optional[float] = None) -> None: """Enter radiofrequency learning mode.""" payload = bytearray() if frequency: payload += struct.pack(" None: + async def cancel_sweep_frequency(self) -> None: """Cancel sweep frequency.""" - self._send(0x1E) + await self._send(0x1E) - def check_sensors(self) -> dict: + async def check_sensors(self) -> dict: """Return the state of the sensors.""" - resp = self._send(0x1) + resp = await self._send(0x1) temp = struct.unpack(" float: + async def check_temperature(self) -> float: """Return the temperature.""" - return self.check_sensors()["temperature"] + return (await self.check_sensors())["temperature"] class rmminib(rmmini): @@ -122,10 +122,10 @@ class rmminib(rmmini): TYPE = "RMMINIB" - def _send(self, command: int, data: bytes = b"") -> bytes: + async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a packet to the device.""" packet = struct.pack(" dict: + async def check_sensors(self) -> dict: """Return the state of the sensors.""" - resp = self._send(0x24) + resp = await self._send(0x24) temp = struct.unpack(" float: + async def check_temperature(self) -> float: """Return the temperature.""" - return self.check_sensors()["temperature"] + return (await self.check_sensors())["temperature"] - def check_humidity(self) -> float: + async def check_humidity(self) -> float: """Return the humidity.""" - return self.check_sensors()["humidity"] + return (await self.check_sensors())["humidity"] class rm4pro(rm4mini, rmpro): diff --git a/broadlink/sensor.py b/broadlink/sensor.py index 284576fa..f0a99029 100644 --- a/broadlink/sensor.py +++ b/broadlink/sensor.py @@ -16,9 +16,9 @@ class a1(Device): ("noise", ("quiet", "normal", "noisy")), ) - def check_sensors(self) -> dict: + async def check_sensors(self) -> dict: """Return the state of the sensors.""" - data = self.check_sensors_raw() + data = await self.check_sensors_raw() for sensor, levels in self._SENSORS_AND_LEVELS: try: data[sensor] = levels[data[sensor]] @@ -26,10 +26,10 @@ def check_sensors(self) -> dict: data[sensor] = "unknown" return data - def check_sensors_raw(self) -> dict: + async def check_sensors_raw(self) -> dict: """Return the state of the sensors in raw format.""" packet = bytearray([0x1]) - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) data = self.decrypt(resp[0x38:]) @@ -47,7 +47,7 @@ class a2(Device): TYPE = "A2" - def _send(self, operation: int, data: Sequence = b""): + async def _send(self, operation: int, data: Sequence = b""): """Send a command to the device.""" packet = bytearray(12) packet[0x02] = 0xA5 @@ -72,14 +72,14 @@ def _send(self, operation: int, data: Sequence = b""): packet[0x00] = packet_len & 0xFF packet[0x01] = packet_len >> 8 - resp = self.send_packet(0x6A, packet) + resp = await self.send_packet(0x6A, packet) e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) return payload - def check_sensors_raw(self) -> dict: + async def check_sensors_raw(self) -> dict: """Return the state of the sensors in raw format.""" - data = self._send(1) + data = await self._send(1) return { "temperature": data[0x13] * 256 + data[0x14], diff --git a/broadlink/switch.py b/broadlink/switch.py index 8393f6b1..b41e220d 100644 --- a/broadlink/switch.py +++ b/broadlink/switch.py @@ -12,11 +12,11 @@ class sp1(Device): TYPE = "SP1" - def set_power(self, pwr: bool) -> None: + async def set_power(self, pwr: bool) -> None: """Set the power state of the device.""" packet = bytearray(4) packet[0] = bool(pwr) - response = self.send_packet(0x66, packet) + response = await self.send_packet(0x66, packet) e.check_error(response[0x22:0x24]) @@ -25,19 +25,19 @@ class sp2(Device): TYPE = "SP2" - def set_power(self, pwr: bool) -> None: + async def set_power(self, pwr: bool) -> None: """Set the power state of the device.""" packet = bytearray(16) packet[0] = 2 packet[4] = bool(pwr) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - def check_power(self) -> bool: + async def check_power(self) -> bool: """Return the power state of the device.""" packet = bytearray(16) packet[0] = 1 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return bool(payload[0x4]) @@ -48,11 +48,11 @@ class sp2s(sp2): TYPE = "SP2S" - def get_energy(self) -> float: + async def get_energy(self) -> float: """Return the power consumption in W.""" packet = bytearray(16) packet[0] = 4 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return int.from_bytes(payload[0x4:0x7], "little") / 1000 @@ -63,36 +63,36 @@ class sp3(Device): TYPE = "SP3" - def set_power(self, pwr: bool) -> None: + async def set_power(self, pwr: bool) -> None: """Set the power state of the device.""" packet = bytearray(16) packet[0] = 2 - packet[4] = self.check_nightlight() << 1 | bool(pwr) - response = self.send_packet(0x6A, packet) + packet[4] = await self.check_nightlight() << 1 | bool(pwr) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - def set_nightlight(self, ntlight: bool) -> None: + async def set_nightlight(self, ntlight: bool) -> None: """Set the night light state of the device.""" packet = bytearray(16) packet[0] = 2 - packet[4] = bool(ntlight) << 1 | self.check_power() - response = self.send_packet(0x6A, packet) + packet[4] = bool(ntlight) << 1 | await self.check_power() + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - def check_power(self) -> bool: + async def check_power(self) -> bool: """Return the power state of the device.""" packet = bytearray(16) packet[0] = 1 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return bool(payload[0x4] & 1) - def check_nightlight(self) -> bool: + async def check_nightlight(self) -> bool: """Return the state of the night light.""" packet = bytearray(16) packet[0] = 1 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return bool(payload[0x4] & 2) @@ -103,10 +103,10 @@ class sp3s(sp2): TYPE = "SP3S" - def get_energy(self) -> float: + async def get_energy(self) -> float: """Return the power consumption in W.""" packet = bytearray([8, 0, 254, 1, 5, 1, 0, 0, 0, 45]) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) energy = payload[0x7:0x4:-1].hex() @@ -118,15 +118,15 @@ class sp4(Device): TYPE = "SP4" - def set_power(self, pwr: bool) -> None: + async def set_power(self, pwr: bool) -> None: """Set the power state of the device.""" - self.set_state(pwr=pwr) + await self.set_state(pwr=pwr) - def set_nightlight(self, ntlight: bool) -> None: + async def set_nightlight(self, ntlight: bool) -> None: """Set the night light state of the device.""" - self.set_state(ntlight=ntlight) + await self.set_state(ntlight=ntlight) - def set_state( + async def set_state( self, pwr: Optional[bool] = None, ntlight: Optional[bool] = None, @@ -151,23 +151,23 @@ def set_state( state["childlock"] = int(bool(childlock)) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) return self._decode(response) - def check_power(self) -> bool: + async def check_power(self) -> bool: """Return the power state of the device.""" - state = self.get_state() + state = await self.get_state() return bool(state["pwr"]) - def check_nightlight(self) -> bool: + async def check_nightlight(self) -> bool: """Return the state of the night light.""" - state = self.get_state() + state = await self.get_state() return bool(state["ntlight"]) - def get_state(self) -> dict: + async def get_state(self) -> dict: """Get full state of device.""" packet = self._encode(1, {}) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) return self._decode(response) def _encode(self, flag: int, state: dict) -> bytes: @@ -196,9 +196,9 @@ class sp4b(sp4): TYPE = "SP4B" - def get_state(self) -> dict: + async def get_state(self) -> dict: """Get full state of device.""" - state = super().get_state() + state = await super().get_state() # Convert sensor data to float. Remove keys if sensors are not supported. sensor_attrs = ["current", "volt", "power", "totalconsum", "overload"] @@ -244,17 +244,17 @@ class bg1(Device): TYPE = "BG1" - def get_state(self) -> dict: + async def get_state(self) -> dict: """Return the power state of the device. Example: `{"pwr":1,"pwr1":1,"pwr2":0,"maxworktime":60,"maxworktime1":60,"maxworktime2":0,"idcbrightness":50}` """ packet = self._encode(1, {}) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) - def set_state( + async def set_state( self, pwr: Optional[bool] = None, pwr1: Optional[bool] = None, @@ -282,7 +282,7 @@ def set_state( state["idcbrightness"] = idcbrightness packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) @@ -321,7 +321,7 @@ class ehc31(bg1): TYPE = "EHC31" - def set_state( + async def set_state( self, pwr: Optional[bool] = None, pwr1: Optional[bool] = None, @@ -367,7 +367,7 @@ def set_state( state["childlock4"] = int(bool(childlock4)) packet = self._encode(2, state) - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) return self._decode(response) @@ -377,7 +377,7 @@ class mp1(Device): TYPE = "MP1" - def set_power_mask(self, sid_mask: int, pwr: bool) -> None: + async def set_power_mask(self, sid_mask: int, pwr: bool) -> None: """Set the power state of the device.""" packet = bytearray(16) packet[0x00] = 0x0D @@ -392,15 +392,15 @@ def set_power_mask(self, sid_mask: int, pwr: bool) -> None: packet[0x0D] = sid_mask packet[0x0E] = sid_mask if pwr else 0 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) - def set_power(self, sid: int, pwr: bool) -> None: + async def set_power(self, sid: int, pwr: bool) -> None: """Set the power state of the device.""" sid_mask = 0x01 << (sid - 1) - self.set_power_mask(sid_mask, pwr) + await self.set_power_mask(sid_mask, pwr) - def check_power_raw(self) -> int: + async def check_power_raw(self) -> int: """Return the power state of the device in raw format.""" packet = bytearray(16) packet[0x00] = 0x0A @@ -412,14 +412,14 @@ def check_power_raw(self) -> int: packet[0x07] = 0xC0 packet[0x08] = 0x01 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) return payload[0x0E] - def check_power(self) -> dict: + async def check_power(self) -> dict: """Return the power state of the device.""" - data = self.check_power_raw() + data = await self.check_power_raw() return { "s1": bool(data & 1), "s2": bool(data & 2), @@ -433,7 +433,7 @@ class mp1s(mp1): TYPE = "MP1S" - def get_state(self) -> dict: + async def get_state(self) -> dict: """Return the power state of the device. voltage in V. @@ -452,7 +452,7 @@ def get_state(self) -> dict: packet[0x08] = 0x01 packet[0x0A] = 0x04 - response = self.send_packet(0x6A, packet) + response = await self.send_packet(0x6A, packet) e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) payload_str = payload.hex()[4:-6] diff --git a/cli/broadlink_cli b/cli/broadlink_cli old mode 100755 new mode 100644 index 7913e332..1014986b --- a/cli/broadlink_cli +++ b/cli/broadlink_cli @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import asyncio import base64 import time from typing import List @@ -57,165 +58,173 @@ parser.add_argument("--joinwifi", nargs=2, help="Args are SSID PASSPHRASE to con parser.add_argument("data", nargs='*', help="Data to send or convert") args = parser.parse_args() -if args.device: - values = args.device.split() - devtype = int(values[0], 0) - host = values[1] - mac = bytearray.fromhex(values[2]) -elif args.mac: - devtype = args.type - host = args.host - mac = bytearray.fromhex(args.mac) - -if args.host or args.device: - dev = broadlink.gendevice(devtype, (host, DEFAULT_PORT), mac) - dev.auth() - -if args.joinwifi: - broadlink.setup(args.joinwifi[0], args.joinwifi[1], 4) - -if args.convert: - data = bytearray.fromhex(''.join(args.data)) - pulses = data_to_pulses(data) - print(format_pulses(pulses)) -if args.temperature: - print(dev.check_temperature()) -if args.humidity: - print(dev.check_humidity()) -if args.energy: - print(dev.get_energy()) -if args.sensors: - data = dev.check_sensors() - for key in data: - print("{} {}".format(key, data[key])) -if args.send: - data = ( - pulses_to_data(parse_pulses(args.data)) - if args.durations - else bytes.fromhex(''.join(args.data)) - ) - dev.send_data(data) -if args.learn or (args.learnfile and not args.rflearn): - dev.enter_learning() - print("Learning...") - start = time.time() - while time.time() - start < TIMEOUT: - time.sleep(1) - try: - data = dev.check_data() - except (ReadError, StorageError): - continue - else: - break - else: - print("No data received...") - exit(1) - - print("Packet found!") - raw_fmt = data.hex() - base64_fmt = base64.b64encode(data).decode('ascii') - pulse_fmt = format_pulses(data_to_pulses(data)) - - print("Raw:", raw_fmt) - print("Base64:", base64_fmt) - print("Pulses:", pulse_fmt) - - if args.learnfile: - print("Saving to {}".format(args.learnfile)) - with open(args.learnfile, "w") as text_file: - text_file.write(pulse_fmt if args.durations else raw_fmt) -if args.check: - if dev.check_power(): - print('* ON *') - else: - print('* OFF *') -if args.checknl: - if dev.check_nightlight(): - print('* ON *') - else: - print('* OFF *') -if args.turnon: - dev.set_power(True) - if dev.check_power(): - print('== Turned * ON * ==') - else: - print('!! Still OFF !!') -if args.turnoff: - dev.set_power(False) - if dev.check_power(): - print('!! Still ON !!') - else: - print('== Turned * OFF * ==') -if args.turnnlon: - dev.set_nightlight(True) - if dev.check_nightlight(): - print('== Turned * ON * ==') - else: - print('!! Still OFF !!') -if args.turnnloff: - dev.set_nightlight(False) - if dev.check_nightlight(): - print('!! Still ON !!') - else: - print('== Turned * OFF * ==') -if args.switch: - if dev.check_power(): - dev.set_power(False) - print('* Switch to OFF *') - else: - dev.set_power(True) - print('* Switch to ON *') -if args.rflearn: - if args.frequency: - frequency = args.frequency - print("Press the button you want to learn, a short press...") - else: - dev.sweep_frequency() - print("Detecting radiofrequency, press and hold the button to learn...") +async def main(): + dev = None + + if args.device: + values = args.device.split() + devtype = int(values[0], 0) + host = values[1] + mac = bytearray.fromhex(values[2]) + elif args.mac: + devtype = args.type + host = args.host + mac = bytearray.fromhex(args.mac) + + if args.host or args.device: + dev = broadlink.gendevice(devtype, (host, DEFAULT_PORT), mac) + await dev.auth() + + if args.joinwifi: + await broadlink.setup(args.joinwifi[0], args.joinwifi[1], 4) + + if args.convert: + data = bytearray.fromhex(''.join(args.data)) + pulses = data_to_pulses(data) + print(format_pulses(pulses)) + if args.temperature: + print(await dev.check_temperature()) + if args.humidity: + print(await dev.check_humidity()) + if args.energy: + print(await dev.get_energy()) + if args.sensors: + data = await dev.check_sensors() + for key in data: + print("{} {}".format(key, data[key])) + if args.send: + data = ( + pulses_to_data(parse_pulses(args.data)) + if args.durations + else bytes.fromhex(''.join(args.data)) + ) + await dev.send_data(data) + if args.learn or (args.learnfile and not args.rflearn): + await dev.enter_learning() + print("Learning...") start = time.time() while time.time() - start < TIMEOUT: - time.sleep(1) - locked, frequency = dev.check_frequency() - if locked: + await asyncio.sleep(1) + try: + data = await dev.check_data() + except (ReadError, StorageError): + continue + else: break else: - print("Radiofrequency not found") - dev.cancel_sweep_frequency() + print("No data received...") exit(1) - print("Radiofrequency detected: {}MHz".format(frequency)) - print("You can now let go of the button") + print("Packet found!") + raw_fmt = data.hex() + base64_fmt = base64.b64encode(data).decode('ascii') + pulse_fmt = format_pulses(data_to_pulses(data)) + + print("Raw:", raw_fmt) + print("Base64:", base64_fmt) + print("Pulses:", pulse_fmt) + + if args.learnfile: + print("Saving to {}".format(args.learnfile)) + with open(args.learnfile, "w") as text_file: + text_file.write(pulse_fmt if args.durations else raw_fmt) + if args.check: + if await dev.check_power(): + print('* ON *') + else: + print('* OFF *') + if args.checknl: + if await dev.check_nightlight(): + print('* ON *') + else: + print('* OFF *') + if args.turnon: + await dev.set_power(True) + if await dev.check_power(): + print('== Turned * ON * ==') + else: + print('!! Still OFF !!') + if args.turnoff: + await dev.set_power(False) + if await dev.check_power(): + print('!! Still ON !!') + else: + print('== Turned * OFF * ==') + if args.turnnlon: + await dev.set_nightlight(True) + if await dev.check_nightlight(): + print('== Turned * ON * ==') + else: + print('!! Still OFF !!') + if args.turnnloff: + await dev.set_nightlight(False) + if await dev.check_nightlight(): + print('!! Still ON !!') + else: + print('== Turned * OFF * ==') + if args.switch: + if await dev.check_power(): + await dev.set_power(False) + print('* Switch to OFF *') + else: + await dev.set_power(True) + print('* Switch to ON *') + if args.rflearn: + if args.frequency: + frequency = args.frequency + print("Press the button you want to learn, a short press...") + else: + await dev.sweep_frequency() + print("Detecting radiofrequency, press and hold the button to learn...") + + start = time.time() + while time.time() - start < TIMEOUT: + await asyncio.sleep(1) + locked, frequency = await dev.check_frequency() + if locked: + break + else: + print("Radiofrequency not found") + await dev.cancel_sweep_frequency() + exit(1) - input("Press enter to continue...") + print("Radiofrequency detected: {}MHz".format(frequency)) + print("You can now let go of the button") - print("Press the button again, now a short press.") + input("Press enter to continue...") - dev.find_rf_packet(frequency) + print("Press the button again, now a short press.") - start = time.time() - while time.time() - start < TIMEOUT: - time.sleep(1) - try: - data = dev.check_data() - except (ReadError, StorageError): - continue + await dev.find_rf_packet(frequency) + + start = time.time() + while time.time() - start < TIMEOUT: + await asyncio.sleep(1) + try: + data = await dev.check_data() + except (ReadError, StorageError): + continue + else: + break else: - break - else: - print("No data received...") - exit(1) - - print("Packet found!") - raw_fmt = data.hex() - base64_fmt = base64.b64encode(data).decode('ascii') - pulse_fmt = format_pulses(data_to_pulses(data)) - - print("Raw:", raw_fmt) - print("Base64:", base64_fmt) - print("Pulses:", pulse_fmt) - - if args.learnfile: - print("Saving to {}".format(args.learnfile)) - with open(args.learnfile, "w") as text_file: - text_file.write(pulse_fmt if args.durations else raw_fmt) + print("No data received...") + exit(1) + + print("Packet found!") + raw_fmt = data.hex() + base64_fmt = base64.b64encode(data).decode('ascii') + pulse_fmt = format_pulses(data_to_pulses(data)) + + print("Raw:", raw_fmt) + print("Base64:", base64_fmt) + print("Pulses:", pulse_fmt) + + if args.learnfile: + print("Saving to {}".format(args.learnfile)) + with open(args.learnfile, "w") as text_file: + text_file.write(pulse_fmt if args.durations else raw_fmt) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cli/broadlink_discovery b/cli/broadlink_discovery old mode 100755 new mode 100644 index 477e1bd7..779a1d21 --- a/cli/broadlink_discovery +++ b/cli/broadlink_discovery @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import asyncio import broadlink from broadlink.const import DEFAULT_BCAST_ADDR, DEFAULT_TIMEOUT @@ -11,20 +12,26 @@ parser.add_argument("--ip", default=None, help="ip address to use in the discove parser.add_argument("--dst-ip", default=DEFAULT_BCAST_ADDR, help="destination ip address to use in the discovery") args = parser.parse_args() -print("Discovering...") -devices = broadlink.discover(timeout=args.timeout, local_ip_address=args.ip, discover_ip_address=args.dst_ip) -for device in devices: - if device.auth(): - print("###########################################") - print(device.type) - print("# broadlink_cli --type {} --host {} --mac {}".format(hex(device.devtype), device.host[0], - ''.join(format(x, '02x') for x in device.mac))) - print("Device file data (to be used with --device @filename in broadlink_cli) : ") - print("{} {} {}".format(hex(device.devtype), device.host[0], ''.join(format(x, '02x') for x in device.mac))) - try: - print("temperature = {}".format(device.check_temperature())) - except (AttributeError, StorageError): - pass - print("") - else: - print("Error authenticating with device : {}".format(device.host)) + +async def main(): + print("Discovering...") + devices = await broadlink.discover(timeout=args.timeout, local_ip_address=args.ip, discover_ip_address=args.dst_ip) + for device in devices: + if await device.auth(): + print("###########################################") + print(device.type) + print("# broadlink_cli --type {} --host {} --mac {}".format(hex(device.devtype), device.host[0], + ''.join(format(x, '02x') for x in device.mac))) + print("Device file data (to be used with --device @filename in broadlink_cli) : ") + print("{} {} {}".format(hex(device.devtype), device.host[0], ''.join(format(x, '02x') for x in device.mac))) + try: + print("temperature = {}".format(await device.check_temperature())) + except (AttributeError, StorageError): + pass + print("") + else: + print("Error authenticating with device : {}".format(device.host)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_oracle.py b/tests/test_oracle.py index 7ae7d26d..f5159d62 100644 --- a/tests/test_oracle.py +++ b/tests/test_oracle.py @@ -36,7 +36,7 @@ def test_every_public_method_is_covered() -> None: # 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"} + "update_aes", "aclose"} missing = [] for name, cls in inspect.getmembers(broadlink, inspect.isclass): if not issubclass(cls, Device): diff --git a/tests/test_transport.py b/tests/test_transport.py index 7fd8e784..37b6447d 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import socket import pytest @@ -21,60 +22,65 @@ INIT_VECT = bytes.fromhex("562e17996d093d28ddb3ba695a2e6f58") -class FakeSocket: - """A UDP socket stand-in: records sendto, replays canned recvfrom.""" +class FakeTransport: + """Datagram transport stand-in: records sendto, feeds canned replies.""" - instances: list["FakeSocket"] = [] - - def __init__(self, *args, **kwargs): - self.sent: list[tuple[bytes, tuple[str, int]]] = [] - self.inbox: list[tuple[bytes, tuple[str, int]]] = list(FakeSocket.queue) - self.timeout = None + 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 - self.bound = None - FakeSocket.instances.append(self) - - queue: list[tuple[bytes, tuple[str, int]]] = [] - - def setsockopt(self, *args): - pass - - def settimeout(self, value): - self.timeout = value - - def bind(self, addr): - self.bound = addr - def getsockname(self): - return self.bound or ("0.0.0.0", 0) + 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 sendto(self, data, addr): - self.sent.append((bytes(data), addr)) + def get_extra_info(self, name): + if name == "sockname": + return (self.local_addr[0], self.local_addr[1] or 40000) + return None - def recvfrom(self, size): - if not self.inbox: - raise socket.timeout() - return self.inbox.pop(0) + def is_closing(self): + return self.closed def close(self): self.closed = True - def __enter__(self): - return self - def __exit__(self, *exc): - self.close() +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 fake_socket(monkeypatch): - FakeSocket.instances = [] - FakeSocket.queue = [] - monkeypatch.setattr(device_module.socket, "socket", FakeSocket) - monkeypatch.setattr(broadlink.socket, "socket", FakeSocket) +def net(monkeypatch): + fake = FakeNet() + monkeypatch.setattr(device_module, "_open_endpoint", fake) + monkeypatch.setattr(broadlink, "_open_endpoint", fake) # Keep the retry loop from waiting on real time. - monkeypatch.setattr(device_module, "DEFAULT_RETRY_INTVL", 0.001) - return FakeSocket + 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: @@ -86,17 +92,18 @@ def fixed_device(cls=Device, devtype=0x2737) -> Device: # ------------------------------------------------------------------ send_packet -def test_send_packet_wire_bytes(fake_socket): +def test_send_packet_wire_bytes(net): dev = fixed_device() dev.id = 0x00000001 payload = bytes([0x01]) + bytes(15) - fake_socket.queue = [(make_response(dev, bytes(16)), HOST)] + net.replies = [(make_response(dev, bytes(16)), HOST)] - resp = dev.send_packet(0x6A, payload) + resp = run(dev.send_packet(0x6A, payload)) - sock = fake_socket.instances[-1] - assert len(sock.sent) == 1 - frame, addr = sock.sent[0] + 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") @@ -116,55 +123,110 @@ def test_send_packet_wire_bytes(fake_socket): assert dev.count == 0x8001 -def test_send_packet_pads_payload_to_block(fake_socket): +def test_send_packet_pads_payload_to_block(net): dev = fixed_device() - fake_socket.queue = [(make_response(dev, b""), HOST)] - dev.send_packet(0x6A, bytes(20)) - frame = fake_socket.instances[-1].sent[0][0] + 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(fake_socket): +def test_send_packet_counter_wraps_with_high_bit(net): dev = fixed_device() dev.count = 0xFFFF - fake_socket.queue = [(make_response(dev, b""), HOST)] - dev.send_packet(0x6A, b"") + net.replies = [(make_response(dev, b""), HOST)] + run(dev.send_packet(0x6A, b"")) assert dev.count == 0x8000 -def test_send_packet_retries_then_times_out(fake_socket, monkeypatch): +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.01 - fake_socket.queue = [] # never answers + dev.timeout = 0.02 + net.replies = [] # never answers with pytest.raises(e.NetworkTimeoutError) as err: - dev.send_packet(0x6A, b"") + run(dev.send_packet(0x6A, b"")) assert err.value.errno == -4000 - assert len(fake_socket.instances[-1].sent) >= 1 + assert len(net.endpoints[-1].sent) >= 2 # resent at least once -def test_send_packet_rejects_short_response(fake_socket): +def test_send_packet_rejects_short_response(net): dev = fixed_device() - fake_socket.queue = [(bytes(0x10), HOST)] + net.replies = [(bytes(0x10), HOST)] with pytest.raises(e.DataValidationError) as err: - dev.send_packet(0x6A, b"") + run(dev.send_packet(0x6A, b"")) assert err.value.errno == -4007 -def test_send_packet_rejects_bad_checksum(fake_socket): +def test_send_packet_rejects_bad_checksum(net): dev = fixed_device() frame = bytearray(make_response(dev, b"")) frame[0x20] ^= 0xFF - fake_socket.queue = [(bytes(frame), HOST)] + net.replies = [(bytes(frame), HOST)] with pytest.raises(e.DataValidationError) as err: - dev.send_packet(0x6A, b"") + run(dev.send_packet(0x6A, b"")) assert err.value.errno == -4008 +def test_stale_reply_is_drained_before_a_request(net): + dev = fixed_device() + + async def go(): + net.replies = [(make_response(dev, b""), HOST)] + await dev.send_packet(0x6A, b"") + ep = net.endpoints[-1] + # A late packet shows up between requests; it must not be taken as + # the answer to the next one. + stale = bytearray(make_response(dev, b"")) + stale[0x20] ^= 0xFF # corrupt so it would fail validation if used + ep.protocol.queue.put_nowait((bytes(stale), HOST)) + ep.replies = [(make_response(dev, bytes([7]) + bytes(15)), HOST)] + resp = await dev.send_packet(0x6A, b"") + return dev.decrypt(resp[0x38:])[0] + + assert run(go()) == 7 + + # ------------------------------------------------------------------------- auth -def test_auth_uses_initial_key_and_installs_session_key(fake_socket): +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 @@ -175,11 +237,11 @@ def test_auth_uses_initial_key_and_installs_session_key(fake_socket): # 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) - fake_socket.queue = [(reply, HOST)] + net.replies = [(reply, HOST)] - assert dev.auth() is True + assert run(dev.auth()) is True - frame = fake_socket.instances[-1].sent[0][0] + 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:]) @@ -196,11 +258,57 @@ def test_auth_uses_initial_key_and_installs_session_key(fake_socket): assert dev.encrypt(bytes(16)) == probe.encrypt(bytes(16)) -def test_auth_surfaces_device_error(fake_socket): +def test_auth_surfaces_device_error(net): dev = fixed_device() - fake_socket.queue = [(make_response(dev, bytes(20), error=0xFFF9), HOST)] + net.replies = [(make_response(dev, bytes(20), error=0xFFF9), HOST)] with pytest.raises(e.AuthorizationError): - dev.auth() + run(dev.auth()) + + +def test_expired_session_is_reauthenticated_once(net): + dev = fixed_device() + dev.id = 5 + session_key = bytes.fromhex("00112233445566778899aabbccddeeff") + fresh = fixed_device() + auth_reply = make_response(fresh, (0x42).to_bytes(4, "little") + session_key) + + async def go(): + # First request: device says the control key expired (-7). + net.replies = [(make_response(dev, b"", error=0xFFF9), HOST)] + await dev._endpoint() + ep = net.endpoints[-1] + # After the expired reply the library must auth (reply 2, under the + # initial key) and resend (reply 3, under the new session key). + renewed = fixed_device() + renewed.update_aes(session_key) + ep.replies = [ + (auth_reply, HOST), + (make_response(renewed, bytes([9]) + bytes(15)), HOST), + ] + ep.replies.insert(0, (make_response(dev, b"", error=0xFFF9), HOST)) + resp = await dev.send_packet(0x6A, bytes(16)) + return ep, resp + + ep, resp = run(go()) + types = [int.from_bytes(f[0x26:0x28], "little") for f, _ in ep.sent] + assert types == [0x6A, 0x65, 0x6A] + assert dev.id == 0x42 + assert resp[0x22:0x24] == b"\x00\x00" + assert dev.decrypt(resp[0x38:])[0] == 9 + + +def test_reauth_is_not_attempted_twice(net): + dev = fixed_device() + + async def go(): + await dev._endpoint() + ep = net.endpoints[-1] + expired = (make_response(dev, b"", error=0xFFF9), HOST) + ep.replies = [expired, expired] # request fails, auth fails + return await dev.send_packet(0x6A, b"") + + with pytest.raises(e.AuthorizationError): + run(go()) # ------------------------------------------------------------------- discovery @@ -215,37 +323,53 @@ def hello_response(devtype: int, mac: bytes, name: str, locked: bool) -> bytes: return bytes(frame) -def test_scan_builds_hello_packet_and_parses_replies(fake_socket): - fake_socket.queue = [ +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, bytes.fromhex("34ea34000001"), "Plug", True), - ("192.0.2.11", 80)), + (hello_response(0x2711, other, "Plug", True), ("192.0.2.11", 80)), ] - found = list(device_module.scan(timeout=0.01, local_ip_address="192.0.2.2")) + 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), bytes.fromhex("34ea34000001"), "Plug", True), + (0x2711, ("192.0.2.11", 80), other, "Plug", True), ] - sock = fake_socket.instances[-1] - assert sock.bound == ("192.0.2.2", 0) - packet, addr = sock.sent[0] + 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 sock.closed + assert ep.closed -def test_discover_and_hello_build_devices(fake_socket): - fake_socket.queue = [ +def test_discover_and_hello_build_devices(net): + net.replies = [ (hello_response(0x6026, MAC, "Bedroom RM", False), ("192.0.2.10", 80)), ] - devices = broadlink.discover(timeout=0.01) + devices = run(broadlink.discover(timeout=0.02)) assert len(devices) == 1 dev = devices[0] assert isinstance(dev, broadlink.rm4pro) @@ -255,45 +379,47 @@ def test_discover_and_hello_build_devices(fake_socket): assert dev.model == "RM4 pro" assert dev.manufacturer == "Broadlink" - fake_socket.queue = [ + net.replies = [ (hello_response(0x6026, MAC, "Bedroom RM", True), ("192.0.2.10", 80)), ] - dev = broadlink.hello("192.0.2.10", timeout=0.01) + dev = run(broadlink.hello("192.0.2.10", timeout=0.02)) assert dev.is_locked is True - assert fake_socket.instances[-1].sent[0][1] == ("192.0.2.10", 80) + assert net.endpoints[-1].sent[0][1] == ("192.0.2.10", 80) + assert net.endpoints[-1].closed -def test_hello_times_out(fake_socket): - fake_socket.queue = [] +def test_hello_times_out(net): + net.replies = [] with pytest.raises(e.NetworkTimeoutError): - broadlink.hello("192.0.2.10", timeout=0.01) + run(broadlink.hello("192.0.2.10", timeout=0.02)) -def test_device_hello_validates_identity(fake_socket): +def test_device_hello_validates_identity(net): dev = fixed_device(broadlink.rm4pro, 0x6026) - fake_socket.queue = [(hello_response(0x6026, MAC, "Renamed", True), HOST)] - assert dev.hello() is True + 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 - fake_socket.queue = [ + net.replies = [ (hello_response(0x6026, bytes.fromhex("000000000001"), "Other", False), HOST) ] with pytest.raises(e.DataValidationError): - dev.hello() + run(dev.hello()) - fake_socket.queue = [(hello_response(0x2711, MAC, "Other", False), HOST)] + net.replies = [(hello_response(0x2711, MAC, "Other", False), HOST)] with pytest.raises(e.DataValidationError): - dev.hello() + run(dev.hello()) -def test_ping_packet(fake_socket): +def test_ping_packet(net): dev = fixed_device() - dev.ping() - packet, addr = fake_socket.instances[-1].sent[0] + 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 @@ -340,10 +466,11 @@ def test_product_table_has_no_duplicate_ids(): # ----------------------------------------------------------------------- setup -def test_setup_packet(fake_socket): - broadlink.setup("MyWifi", "hunter2", 3, ip_address="192.0.2.255") - packet, addr = fake_socket.instances[-1].sent[0] +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" @@ -354,6 +481,7 @@ def test_setup_packet(fake_socket): 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 From 354128ca8af5cf1aa43cab56b159b8fe1325acb1 Mon Sep 17 00:00:00 2001 From: David <128871138+DAB-LABS@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:26:34 -0700 Subject: [PATCH 3/8] Devices and tick (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for Broadlink RM Max (0xAF8B) Place in the rmpro class which uses * Update __init__.py * Update remote.py * Add an OEM device code for RM mini 3 * support RM mini 3 cmcc version support RM mini 3 cmcc version * Added support for another type of LB26 R1 * Update __init__.py add 0x7d15 SP mini 3-AL * Add support for LEDVANCE SMART+ WIFI CEILING TW 24W (0x6498) * Refactor tick parameter to use constant value * Add tests for TICK constant in remote module Add unit tests for the TICK constant in remote module to ensure accuracy against protocol.md examples and validate behavior with real hardware. * Round pulses to the nearest tick, add issue-reported device IDs, update tests and changelog Follow-ups to the carried-over commits: - pulses_to_data rounds instead of truncating, so a duration that is 0.9 of a tick no longer becomes zero ticks. - TICK gets a docstring explaining the 32768 Hz timebase and the history of the 32.84 value; the import block is sorted for ruff. - 0x4EDA MP1-1K3S2U (#816) and 0xA57A SP4 (#758) added from issues, by family; 0x7D15 and 0x27C8 entries tidied into hex order and house style. - cryptography floor raised to 43, the first release with 3.13 wheels (supersedes #749). - tests/test_helpers.py re-pinned to the new tick and rounding; the old 32.84 pins are gone. - README device list and CHANGELOG updated. --------- Co-authored-by: Alexey Masolov Co-authored-by: Cursor Co-authored-by: Anil Daoud Co-authored-by: Bartłomiej Nogaś Co-authored-by: shuxin Co-authored-by: techitapart <70172453+techitapart@users.noreply.github.com> Co-authored-by: bbcbbk <44605459+bbcbbk@users.noreply.github.com> Co-authored-by: Felipe Martins Diel --- CHANGELOG.md | 27 +++++++++++++++++++ README.md | 6 ++--- broadlink/__init__.py | 13 ++++++++- broadlink/remote.py | 23 +++++++++++++--- pyproject.toml | 2 +- tests/test_helpers.py | 38 ++++++++++++++++++-------- tests/test_remote.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 153 insertions(+), 19 deletions(-) create mode 100644 tests/test_remote.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b44e43a..fb74a4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,8 +39,35 @@ history below starts at that fork point. 3.14, and builds the sdist and wheel on every pull request. Releases are published to PyPI from version tags using trusted publishing. +### Fixed + +- The IR tick constant used by `pulses_to_data` and `data_to_pulses` is now + `TICK = 8192 / 269` (about 30.45 us), matching the device's 32768 Hz + timebase as documented in `protocol.md`. The previous value, 32.84, was + the inverse ratio applied the wrong way round and compressed IR codes + built from true microsecond timings by about 7 percent. Codes learned and + replayed through the same device were unaffected. Verified on an RM4 Pro + against an independent receiver in both directions. + (mjg59/python-broadlink#839, #841) +- `pulses_to_data` rounds each duration to the nearest tick instead of + truncating, which removes up to one tick of systematic shortening per + pulse. + ### Added +- Devices, carried over from pull requests against the original repository + with their authors' commits intact: RM Max 0xAF8B (#838, Alexey Masolov); + RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3 + OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802, + shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805, + bbcbbk); LEDVANCE SMART+ WIFI CEILING TW 24W 0x6498 (#799, Felipe Martins + Diel). +- Devices reported in issues against the original repository, added by + model name to the existing class for that family and not yet confirmed on + hardware: MP1-1K3S2U 0x4EDA (#816) and SP4 0xA57A (#758). Please open an + issue if either does not behave. +- `cryptography` 43 or newer is required, the first release with wheels for + Python 3.13 (supersedes mjg59/python-broadlink#749). - A test suite. The `tests/oracle` package records the exact request bytes every public method of every device class sends, and the results it decodes from canned responses, so that later changes to the transport diff --git a/README.md b/README.md index a8babf31..f3fe226f 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,14 @@ behaviour, pin the original distribution (`broadlink==0.19.0`) instead. The following devices are supported: -- **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate -- **Smart plugs**: SP mini, SP mini 3, SP mini+, SP1, SP2, SP2-BR, SP2-CL, SP2-IN, SP2-UK, SP3, SP3-EU, SP3S-EU, SP3S-US, SP4L-AU, SP4L-EU, SP4L-UK, SP4M, SP4M-US, Ankuoo NEO, Ankuoo NEO PRO, Efergy Ego, BG AHC/U-01 +- **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate, RM Max, RM5 plus +- **Smart plugs**: SP mini, SP mini 3, SP mini+, SP1, SP2, SP2-BR, SP2-CL, SP2-IN, SP2-UK, SP3, SP3-EU, SP3S-EU, SP3S-US, SP4L-AU, SP4L-EU, SP4L-UK, SP4M, SP4M-US, SP mini 3-AL, Ankuoo NEO, Ankuoo NEO PRO, Efergy Ego, BG AHC/U-01 - **Switches**: MCB1, SC1, SCB1E, SCB2 - **Outlets**: BG 800, BG 900 - **Power strips**: MP1-1K3S2U, MP1-1K4S, MP2 - **Environment sensors**: A1 - **Alarm kits**: S1C, S2KIT -- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD +- **Light bulbs**: LB1, LB26 R1, LB27 R1, SB800TD, LEDVANCE SMART+ WIFI CEILING TW 24W - **Curtain motors**: Dooya DT360E-45/20 - **Thermostats**: Hysen HY02B05H - **Hubs**: S3 diff --git a/broadlink/__init__.py b/broadlink/__init__.py index bd77fa2a..eab43e72 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -11,7 +11,7 @@ from .device import Device, _open_endpoint, ping, scan from .hub import s3 from .light import lb1, lb2 -from .remote import rm, rm4, rm4mini, rm4pro, rmmini, rmminib, rmpro +from .remote import rm, rm4, rm4mini, rm4pro, rm5plus, rmmini, rmminib, rmpro from .sensor import a1, a2 from .switch import bg1, ehc31, mp1, mp1s, sp1, sp2, sp2s, sp3, sp3s, sp4, sp4b @@ -63,6 +63,7 @@ 0x7583: ("SP mini 3", "Broadlink"), 0x7587: ("SP4L-UK", "Broadlink"), 0x7D11: ("SP mini 3", "Broadlink"), + 0x7D15: ("SP mini 3-AL", "Broadlink (OEM)"), 0xA4F9: ("WS4", "Broadlink (OEM)"), 0xA569: ("SP4L-UK", "Broadlink"), 0xA56A: ("MCB1", "Broadlink"), @@ -71,6 +72,7 @@ 0xA576: ("SP4L-AU", "Broadlink"), 0xA589: ("SP4L-UK", "Broadlink"), 0xA5D3: ("SP4L-EU", "Broadlink"), + 0xA57A: ("SP4", "Broadlink"), 0xA6F4: ("SP4D-US", "Broadlink"), }, sp4b: { @@ -90,6 +92,7 @@ 0x27B7: ("RM mini 3", "Broadlink"), 0x27C2: ("RM mini 3", "Broadlink"), 0x27C7: ("RM mini 3", "Broadlink"), + 0x27C8: ("RM mini 3", "Broadlink"), # CMCC version 0x27CC: ("RM mini 3", "Broadlink"), 0x27CD: ("RM mini 3", "Broadlink"), 0x27D0: ("RM mini 3", "Broadlink"), @@ -97,6 +100,7 @@ 0x27D3: ("RM mini 3", "Broadlink"), 0x27DC: ("RM mini 3", "Broadlink"), 0x27DE: ("RM mini 3", "Broadlink"), + 0xA544: ("RM mini 3", "Broadlink (OEM)"), }, rmpro: { 0x2712: ("RM pro/pro+", "Broadlink"), @@ -112,6 +116,7 @@ 0x27A6: ("RM plus", "Broadlink"), 0x27A9: ("RM pro+", "Broadlink"), 0x27C3: ("RM pro+", "Broadlink"), + 0xAF8B: ("RM Max", "Broadlink"), }, rmminib: { 0x5F36: ("RM mini 3", "Broadlink"), @@ -147,6 +152,9 @@ 0x649B: ("RM4 pro", "Broadlink"), 0x653C: ("RM4 pro", "Broadlink"), }, + rm5plus: { + 0x5224: ("RM5 plus", "Broadlink"), + }, a1: { 0x2714: ("A1", "Broadlink"), }, @@ -155,6 +163,7 @@ }, mp1: { 0x4EB5: ("MP1-1K4S", "Broadlink"), + 0x4EDA: ("MP1-1K3S2U", "Broadlink"), 0x4F1B: ("MP1-1K3S2U", "Broadlink (OEM)"), 0x4F65: ("MP1-1K3S2U", "Broadlink"), }, @@ -173,11 +182,13 @@ 0x644C: ("LB27 R1", "Broadlink"), 0x644E: ("LB26 R1", "Broadlink"), 0x6488: ("LB27 C1", "Broadlink"), + 0x6498: ("SMART+ WIFI CEILING TW 24W", "LEDVANCE"), }, lb2: { 0xA4F4: ("LB27 R1", "Broadlink"), 0xA5F7: ("LB27 R1", "Broadlink"), 0xA6EF: ("EFCF60WSMT", "Luceco"), + 0xA517: ("LB26 R1", "Broadlink"), }, S1C: { 0x2722: ("S2KIT", "Broadlink"), diff --git a/broadlink/remote.py b/broadlink/remote.py index 64103882..2aa3c464 100644 --- a/broadlink/remote.py +++ b/broadlink/remote.py @@ -1,18 +1,29 @@ """Support for universal remotes.""" + import struct from typing import List, Optional, Tuple from . import exceptions as e from .device import Device +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. +""" + -def pulses_to_data(pulses: List[int], tick: float = 32.84) -> bytes: +def pulses_to_data(pulses: List[int], tick: float = TICK) -> bytes: """Convert a microsecond duration sequence into a Broadlink IR packet.""" result = bytearray(4) result[0x00] = 0x26 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) @@ -25,7 +36,7 @@ def pulses_to_data(pulses: List[int], tick: float = 32.84) -> bytes: return 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 @@ -171,3 +182,9 @@ class rm4(rm4pro): """For backwards compatibility.""" TYPE = "RM4" + + +class rm5plus(rmminib): + """Controls a Broadlink RM5 Plus.""" + + TYPE = "RM5PLUS" diff --git a/pyproject.toml b/pyproject.toml index 7c223b33..52abb677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ "Topic :: Home Automation", ] dependencies = [ - "cryptography>=3.2", + "cryptography>=43", ] [project.optional-dependencies] diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 04629daf..27db245f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -8,36 +8,52 @@ from broadlink.helpers import CRC16 from broadlink.protocol import Datetime -from broadlink.remote import data_to_pulses, pulses_to_data +from broadlink.remote import TICK, data_to_pulses, pulses_to_data -# NOTE: these pin the 0.19.0 behavior of the pulse helpers, including the -# 32.84 tick that upstream issue #839 identifies as wrong. They are expected -# to change, deliberately and in the same pull request, when the tick fix -# lands; until then they document what shipped. + +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], tick=32.84) + data = pulses_to_data([328, 656]) assert data[0] == 0x26 assert data[1] == 0x00 assert int.from_bytes(data[2:4], "little") == 2 - assert data[4:] == bytes([9, 19]) # floor(328/32.84)=9, floor(656/32.84)=19 + # 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], tick=32.84) - ticks = int(10000 // 32.84) # 304 + 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) - # Both directions use the same tick, so the round trip lands within a tick. + # 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) <= 33 + assert abs(a - b) <= TICK / 2 + 1 def test_data_to_pulses_honors_declared_length(): diff --git a/tests/test_remote.py b/tests/test_remote.py new file mode 100644 index 00000000..c0f7f06a --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,63 @@ +"""Tests for the tick constant used by pulses_to_data / data_to_pulses (GH #839).""" +import unittest + +from broadlink.remote import TICK, data_to_pulses, pulses_to_data + +OLD_TICK = 32.84 # the constant this PR replaces + + +class TestTickConstant(unittest.TestCase): + """TICK must match protocol.md's worked examples (its "us * 269 / 8192" formula).""" + + def test_tick_matches_protocol_md(self): + # protocol.md's literal, verified-by-example formula. + self.assertAlmostEqual(TICK, 8192 / 269, places=4) + + def test_protocol_md_worked_examples(self): + # protocol.md's own worked examples, applying its own formula + # (us * 269 / 8192) literally: 8920 us -> 0x124 (292 ticks), + # 4450 us -> 0x92 (146 ticks). TICK = 8192 / 269 reproduces both + # exactly; the alternative reading "2^-15 s" (1e6 / 2**15, + # 30.5176 us) is 0.2% different and lands one tick short on the + # second example under floor division. See PR discussion for why + # 8192/269 is the better-evidenced choice pending hardware bench. + for us, expected_ticks in ((8920, 292), (4450, 146)): + got = int(us // TICK) + self.assertLessEqual(abs(got - expected_ticks), 1) + old_got = int(us // OLD_TICK) + self.assertGreater(abs(old_got - expected_ticks), 5) + + def test_round_trip(self): + # Learn-then-send is unaffected by which tick is used, as long as + # both directions agree -- this must hold for TICK just as it held + # for the old constant. + pulses = [9000, 4500, 560, 1690, 560, 560] + packet = pulses_to_data(pulses) + decoded = data_to_pulses(packet) + for original, result in zip(pulses, decoded): + self.assertAlmostEqual(result, original, delta=TICK) + + def test_true_microsecond_nec_leader_is_now_correct(self): + # A real NEC leader (9000/4500 us) built from TRUE microseconds + # (e.g. Home Assistant's infrared platform, not a Broadlink round + # trip) must decode back to ~9000/4500, not ~7% short. + packet = pulses_to_data([9000, 4500]) + decoded = data_to_pulses(packet) + self.assertAlmostEqual(decoded[0], 9000, delta=50) + self.assertAlmostEqual(decoded[1], 4500, delta=50) + + def test_old_constant_was_seven_percent_short_on_real_hardware(self): + # The silicon's timebase is fixed regardless of what the software + # assumed, so what the old code actually put on the wire for a + # true-microsecond input is tick_count * TICK, not + # tick_count * OLD_TICK. This reproduces the ~7% figure from the + # issue's hardware bench (8362us/8437us measured vs ~9000/9116us + # true, same ballpark once packet framing rounding is folded in). + buggy_packet = pulses_to_data([9000, 4500], tick=OLD_TICK) + actually_transmitted = data_to_pulses(buggy_packet, tick=TICK) + self.assertLess(actually_transmitted[0], 9000 - 500) + self.assertLess(actually_transmitted[1], 4500 - 250) + + +if __name__ == "__main__": + unittest.main() From 5f136a33752e9e9e8eacaa486ccf923a845c91b8 Mon Sep 17 00:00:00 2001 From: David <128871138+DAB-LABS@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:37:48 -0700 Subject: [PATCH 4/8] Add capture() and capture_rf() with packet helpers (#4) A learning session is arm, poll, wait, re-arm, one code at a time, with a device that leaves learning mode without saying so and a send that ends the session. capture() and capture_rf() own that loop and hand back clean signals, so a consumer subscribes and reads instead of reimplementing the dance (as the remote platform, the receiver PR and others each did). - capture(window, stop_after_first, poll_interval, rearm_interval): async generator yielding CapturedSignal. Re-arms on a timer (default 15 s, under the 25-40 s the device was measured to hold a session) and after any send_data (a send ends the session; both from the bench). One window per device; a second raises CaptureInProgressError. - capture_rf(window, frequency, ...) on the Pro classes: takes the carrier directly, or sweeps for it when not given. The sweep is unreliable on some firmware, so the known-frequency path is primary. - CapturedSignal: device packet, pulses at the corrected tick, kind, repeat, and the RF carrier the packet does not itself record. - pulses_to_data gains kind and repeat; parse_packet is the inverse; SignalKind names the bands. A returned RF packet does not always carry the canonical type byte (an RM4 Pro sends 0xB1 for 433 MHz), so kind is read by band and a capture is tagged from what it armed, never dropped on the byte. - One shared front-end lock and a transmit generation counter already live on the device; capture reads the counter so a concurrent send re-arms the window. Tests drive the loops against a scripted device that models the bench findings; the transport oracle fixtures are unchanged. --- CHANGELOG.md | 16 ++ README.md | 40 +++ broadlink/exceptions.py | 9 + broadlink/remote.py | 341 ++++++++++++++++++++++++- tests/test_capture.py | 548 ++++++++++++++++++++++++++++++++++++++++ tests/test_oracle.py | 3 + 6 files changed, 952 insertions(+), 5 deletions(-) create mode 100644 tests/test_capture.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fb74a4b4..cc1a750d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,22 @@ history below starts at that fork point. ### Added +- `capture()` and `capture_rf()`, async generators that own the arm, poll, + timeout and re-arm loop of a learning session and yield each signal as a + `CapturedSignal` (device packet, decoded pulses at the correct tick, + kind, repeat count, and for RF the carrier frequency). They re-arm on a + timer, because the device leaves learning mode silently, and after any + `send_data`, because a transmission ends the session; both intervals and + the poll cadence were set from a bench on an RM4 Pro. Only one window can + be open per device. `capture_rf()` (Pro models only) takes the carrier + frequency directly and falls back to the on-device sweep when it is not + given. +- Packet helpers: `pulses_to_data` takes `kind` and `repeat`, `parse_packet` + is its inverse, and `SignalKind` names the IR, 433 MHz and 315 MHz bands. + A device's returned RF packet does not always use the canonical type byte + (an RM4 Pro answers a 433 MHz capture with 0xB1, not 0xB2), so the kind is + read by band and a capture is tagged from what it armed rather than the + byte. - Devices, carried over from pull requests against the original repository with their authors' commits intact: RM Max 0xAF8B (#838, Alexey Masolov); RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3 diff --git a/README.md b/README.md index f3fe226f..6d5d4d5f 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,46 @@ You can exit the learning mode in the middle of the process by calling this meth await device.cancel_sweep_frequency() ``` +### Capturing signals + +`capture()` wraps the arm, poll, timeout and re-arm dance above into one +async generator that yields each signal it hears as a `CapturedSignal`: + +```python3 +from contextlib import aclosing + +async with aclosing(device.capture(window=30)) as signals: + async for signal in signals: + print(signal.kind, len(signal.pulses), "pulses") + await other_device.send_data(signal.packet) +``` + +By default the window closes after the first signal. Pass +`stop_after_first=False` to keep it open for the whole `window` (in seconds; +`window=0` runs until the generator is closed), re-arming after each signal +because the device holds only one code per learning session. A universal +remote has a single receiver, so only one capture window can be open on a +device at a time. + +`CapturedSignal` carries the device's own `packet` bytes (ready for +`send_data`), the decoded `pulses` in microseconds at the correct tick, the +`kind` (`SignalKind.IR`, `RF_433` or `RF_315`), the `repeat` count, and for +RF the `frequency_mhz` the packet itself does not record. + +RF works the same way on the Pro models, with the carrier as the one extra +input: + +```python3 +async with aclosing(device.capture_rf(window=30, frequency=433.92)) as signals: + async for signal in signals: + ... +``` + +Pass `frequency` whenever you know it. Without it the device first sweeps +for the carrier while you hold a button down, then learns the code from a +fresh press; the sweep is unreliable on some firmware and can report a +carrier it never really locked, so the known-frequency path is preferred. + ### Sending IR/RF packets ```python3 await device.send_data(packet) diff --git a/broadlink/exceptions.py b/broadlink/exceptions.py index 2343ad6e..8f2ecc6c 100644 --- a/broadlink/exceptions.py +++ b/broadlink/exceptions.py @@ -95,6 +95,15 @@ class StorageError(BroadlinkException): """Storage error.""" +class CaptureInProgressError(BroadlinkException): + """A capture window is already open on this device. + + A universal remote has one receiver, so only one ``capture`` or + ``capture_rf`` window can be open at a time. Close the running one + before opening another. + """ + + class WriteError(BroadlinkException): """Write error.""" diff --git a/broadlink/remote.py b/broadlink/remote.py index 2aa3c464..9f905618 100644 --- a/broadlink/remote.py +++ b/broadlink/remote.py @@ -1,7 +1,11 @@ """Support for universal remotes.""" +import asyncio +import enum import struct -from typing import List, Optional, Tuple +import time +from dataclasses import dataclass, field +from typing import AsyncIterator, Awaitable, Callable, List, Optional, Tuple from . import exceptions as e from .device import Device @@ -16,11 +20,73 @@ 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.""" -def pulses_to_data(pulses: List[int], tick: float = TICK) -> bytes: - """Convert a microsecond duration sequence into a Broadlink IR packet.""" +DEFAULT_REARM_INTERVAL = 15.0 +"""Seconds after which an open capture window re-enters learning mode. + +The RM4 Pro leaves learning mode silently between 25 s and 40 s after +``enter_learning`` (bench, 2026-09-04), and any ``send_data`` also ends the +session, while ``check_data`` keeps answering with the same "nothing yet" +error, so an open window has to re-arm on a timer and after every send. +""" + + +class SignalKind(enum.IntEnum): + """The kind of signal a packet carries. + + The values are the canonical type bytes the library writes when it + builds a packet (protocol.md offset 0x00). Packets a device returns + from a learn session do not always use exactly these bytes -- an RM4 + Pro returns 0xB1 for a 433 MHz capture, not 0xB2 -- so read a returned + packet's kind with ``classify`` rather than by equality. + """ + + IR = 0x26 + RF_433 = 0xB2 + RF_315 = 0xD7 + + @property + def is_rf(self) -> bool: + return self is not SignalKind.IR + + @classmethod + def classify(cls, type_byte: int) -> "SignalKind": + """Map a packet's raw first byte to a kind, tolerantly. + + The RF learn path returns bytes in the 0xB_ (433 MHz) and 0xD_ + (315 MHz) ranges whose low bits are not documented and vary by + firmware, so classify by range rather than by exact value. Raises + ``ValueError`` for a byte in no known range. + """ + if type_byte == cls.IR: + return cls.IR + if type_byte & 0xF0 == 0xB0: + return cls.RF_433 + if type_byte & 0xF0 == 0xD0: + return cls.RF_315 + raise ValueError(f"Unknown packet type 0x{type_byte:02x}") + + +def pulses_to_data( + pulses: List[int], + tick: float = TICK, + *, + kind: SignalKind = SignalKind.IR, + repeat: int = 0, +) -> bytes: + """Convert a microsecond duration sequence into a Broadlink packet. + + ``kind`` selects the type byte (IR, RF 433 MHz or RF 315 MHz) and + ``repeat`` is the number of extra transmissions the device performs + after the first, 0 to 255 (protocol.md offset 0x01). + """ + if not 0 <= repeat <= 0xFF: + raise ValueError("repeat must be between 0 and 255") result = bytearray(4) - result[0x00] = 0x26 + result[0x00] = SignalKind(kind) + result[0x01] = repeat for pulse in pulses: div, mod = divmod(round(pulse / tick), 256) @@ -33,7 +99,7 @@ def pulses_to_data(pulses: List[int], tick: float = TICK) -> bytes: result[0x02] = data_len & 0xFF result[0x03] = data_len >> 8 - return result + return bytes(result) def data_to_pulses(data: bytes, tick: float = TICK) -> List[int]: @@ -58,11 +124,98 @@ def data_to_pulses(data: bytes, tick: float = TICK) -> List[int]: return result +@dataclass(frozen=True) +class ParsedPacket: + """The parts of a Broadlink packet: kind, repeat count and timings. + + ``type_byte`` is the packet's raw first byte; ``kind`` is that byte + classified into a band (see ``SignalKind.classify``), which for a + device-returned RF packet is not always the canonical value. + """ + + kind: SignalKind + repeat: int + pulses: List[int] + type_byte: int + + +def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket: + """Split a Broadlink packet into its kind, repeat count and timings. + + Raises ``ValueError`` if the packet is shorter than its header or the + type byte is in no known band (IR, 433 MHz or 315 MHz). + """ + if len(data) < 4: + raise ValueError("Malformed data.") + kind = SignalKind.classify(data[0x00]) + return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00]) + + +@dataclass(frozen=True) +class CapturedSignal: + """One signal captured by a universal remote. + + ``packet`` is the device's own bytes, ready for ``send_data`` and for + storage; ``pulses`` is the same signal as microsecond durations at the + corrected tick. ``kind`` is the band the signal was captured on; + ``type_byte`` is the packet's raw first byte, which for RF is not always + the canonical value for the band. ``frequency_mhz`` is set for RF + captures only and holds the carrier the device swept to or was given, + which the packet itself does not record. + """ + + packet: bytes + kind: SignalKind + pulses: List[int] = field(repr=False) + repeat: int = 0 + frequency_mhz: Optional[float] = None + type_byte: Optional[int] = None + captured_at: float = field(default_factory=time.time, repr=False) + + @classmethod + def from_packet( + cls, + packet: bytes, + frequency_mhz: Optional[float] = None, + *, + kind: Optional[SignalKind] = None, + ) -> "CapturedSignal": + """Build a signal from a device-returned packet. + + ``kind`` overrides the band read from the packet's type byte. A + capture window knows what it armed, so it passes the kind it armed + for and a signal is never dropped over an unexpected type byte; the + raw byte is still kept in ``type_byte``. The timings are read from + the packet regardless of the type byte. + """ + if len(packet) < 4: + raise ValueError("Malformed data.") + type_byte = packet[0x00] + if kind is None: + kind = SignalKind.classify(type_byte) + return cls( + bytes(packet), + kind, + data_to_pulses(packet), + packet[0x01], + frequency_mhz, + type_byte, + ) + + class rmmini(Device): """Controls a Broadlink RM mini 3.""" TYPE = "RMMINI" + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + # Bumped by every transmission. An open capture window compares it + # against the value it saw when it armed the device and re-arms + # after any send, since the device has one front end for both. + self._tx_generation = 0 + self._capture_open = False + async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a packet to the device.""" packet = struct.pack(" None: async def send_data(self, data: bytes) -> None: """Send a code to the device.""" + self._tx_generation += 1 await self._send(0x2, data) async def enter_learning(self) -> None: @@ -89,6 +243,104 @@ async def check_data(self) -> bytes: """Return the last captured code.""" return await self._send(0x4) + def capture( + self, + window: float = 30.0, + *, + stop_after_first: bool = True, + poll_interval: float = DEFAULT_POLL_INTERVAL, + rearm_interval: float = DEFAULT_REARM_INTERVAL, + ) -> AsyncIterator[CapturedSignal]: + """Open an infrared capture window and yield what the device hears. + + The device is put into learning mode and polled every + ``poll_interval`` seconds. Each code it reports is yielded as a + ``CapturedSignal``. With ``stop_after_first`` the window closes + after the first code; otherwise the device is re-armed after each + code (it holds one code per learning session) and the window stays + open until ``window`` seconds have passed. ``window=0`` keeps it + open until the generator is closed. + + The device leaves learning mode on its own after a while without + saying so, so the window re-arms it every ``rearm_interval`` seconds + and after every ``send_data`` on the same device. Closing the + generator sends nothing further; the device times out by itself. + Use ``contextlib.aclosing`` (or iterate to the end) so the window is + released promptly. Only one capture window can be open per device; + a second raises ``CaptureInProgressError``. + """ + return self._capture_loop( + self.enter_learning, + window, + stop_after_first, + poll_interval, + rearm_interval, + SignalKind.IR, + None, + ) + + async def _capture_loop( + self, + arm: Callable[[], Awaitable[None]], + window: float, + stop_after_first: bool, + poll_interval: float, + rearm_interval: float, + kind: SignalKind, + frequency_mhz: Optional[float], + ) -> AsyncIterator[CapturedSignal]: + if window < 0: + raise ValueError("window must be 0 (open-ended) or positive") + if poll_interval <= 0 or rearm_interval <= 0: + raise ValueError("poll_interval and rearm_interval must be positive") + if self._capture_open: + raise e.CaptureInProgressError("A capture window is already open") + + self._capture_open = True + try: + loop = asyncio.get_running_loop() + deadline = loop.time() + window if window else None + timeouts = 0 + + await arm() + armed_at = loop.time() + generation = self._tx_generation + + while True: + now = loop.time() + if deadline is not None and now >= deadline: + return + delay = poll_interval + if deadline is not None: + delay = min(delay, deadline - now) + await asyncio.sleep(delay) + + try: + data = await self.check_data() + except e.StorageError: + data = b"" # The device's answer for "nothing yet". + except e.NetworkTimeoutError: + timeouts += 1 + if timeouts >= 3: + raise + generation = -1 # Re-arm; the device's state is unknown. + continue + timeouts = 0 + + if data: + yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind) + if stop_after_first: + return + generation = -1 # One code per session: re-arm. + + now = loop.time() + if generation != self._tx_generation or now - armed_at >= rearm_interval: + await arm() + armed_at = loop.time() + generation = self._tx_generation + finally: + self._capture_open = False + class rmpro(rmmini): """Controls a Broadlink RM pro.""" @@ -117,6 +369,85 @@ async def cancel_sweep_frequency(self) -> None: """Cancel sweep frequency.""" await self._send(0x1E) + async def capture_rf( + self, + window: float = 30.0, + *, + frequency: Optional[float] = None, + stop_after_first: bool = True, + poll_interval: float = DEFAULT_POLL_INTERVAL, + rearm_interval: float = DEFAULT_REARM_INTERVAL, + ) -> AsyncIterator[CapturedSignal]: + """Open a radio frequency capture window and yield what the device hears. + + With ``frequency`` (in MHz, for example 433.92) the device goes + straight into RF learning mode on that carrier. Without it the + device first sweeps for the carrier while the user HOLDS a button on + the remote, and only then learns the code from a fresh press; the + sweep is unreliable on some firmware and can report a carrier it + never really locked, so pass the frequency whenever it is known. + + The window, polling, re-arm and stop-after-first semantics are those + of ``capture``; the sweep counts against the same ``window``. A + ``send_data`` during the sweep restarts it. Each ``CapturedSignal`` + carries the carrier in ``frequency_mhz``, which the packet itself + does not record. + """ + if self._capture_open: + raise e.CaptureInProgressError("A capture window is already open") + if window < 0 or poll_interval <= 0: + raise ValueError("window must be 0 or positive, poll_interval positive") + + loop = asyncio.get_running_loop() + deadline = loop.time() + window if window else None + + if frequency is None: + self._capture_open = True + try: + frequency = await self._sweep(deadline, poll_interval) + finally: + self._capture_open = False + if frequency is None: + return + if deadline is not None: + window = max(deadline - loop.time(), 0.0) + if window == 0: + return + + async def arm() -> None: + await self.find_rf_packet(frequency) + + kind = SignalKind.RF_315 if frequency < 400 else SignalKind.RF_433 + async for signal in self._capture_loop( + arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency + ): + yield signal + + async def _sweep( + self, deadline: Optional[float], poll_interval: float + ) -> Optional[float]: + """Sweep for the remote's carrier; return it in MHz, or None if the + window ran out first.""" + loop = asyncio.get_running_loop() + await self.sweep_frequency() + generation = self._tx_generation + while True: + now = loop.time() + if deadline is not None and now >= deadline: + await self.cancel_sweep_frequency() + return None + delay = poll_interval + if deadline is not None: + delay = min(delay, deadline - now) + await asyncio.sleep(delay) + if generation != self._tx_generation: + await self.sweep_frequency() + generation = self._tx_generation + continue + found, frequency = await self.check_frequency() + if found: + return frequency + async def check_sensors(self) -> dict: """Return the state of the sensors.""" resp = await self._send(0x1) diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 00000000..14ed8c50 --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,548 @@ +"""Capture windows and packet helpers, against a scripted universal remote. + +The fake replaces ``send_packet`` on a real device instance, decodes the +command the class framed, and behaves like an RM4 Pro as measured on the +bench: it holds one code per learning session, answers ``check_data`` with +``StorageError`` -5 until a code lands, and drops presses while not armed. +""" + +from __future__ import annotations + +import asyncio +import struct +from contextlib import aclosing + +import pytest + +import broadlink +from broadlink import exceptions as e +from broadlink.remote import ( + CapturedSignal, + SignalKind, + data_to_pulses, + parse_packet, + pulses_to_data, +) +from tests.oracle.harness import HOST, MAC, make_response + +IR = pulses_to_data([9000, 4500, 560, 560, 560, 1690]) +RF = pulses_to_data([300, 900, 300, 900], kind=SignalKind.RF_433) + +CMD_SEND = 0x02 +CMD_LEARN = 0x03 +CMD_CHECK = 0x04 +CMD_SWEEP = 0x19 +CMD_CHECK_FREQ = 0x1A +CMD_FIND_RF = 0x1B +CMD_CANCEL_SWEEP = 0x1E + + +class FakeRM: + """A scripted RM: one receiver, one code per arm, silent expiry.""" + + def __init__(self, device: broadlink.Device, framing: str) -> None: + self.device = device + self.framing = framing # "rmmini" ( bool: + """A remote is pressed at the device. Captured only while armed.""" + if not self.armed: + return False + self.pending = packet + self.armed = False # One code per learning session. + return True + + def expire(self) -> None: + """The device leaves learning mode without telling anyone.""" + self.armed = False + + # -- fake transport + async def send_packet(self, packet_type: int, payload: bytes) -> bytes: + assert packet_type == 0x6A + if self.framing == "rmmini": + command = struct.unpack(" tuple[bytes, int | str]: + if command == CMD_LEARN: + self.armed = True + self.rf_frequency = None + return b"", 0 + if command == CMD_FIND_RF: + self.armed = True + self.rf_frequency = struct.unpack(" int: + return sum(1 for c, _ in self.commands if c == command) + + +def make(cls_name: str = "rm4pro", devtype: int = 0x649B) -> tuple[broadlink.Device, FakeRM]: + cls = getattr(broadlink, cls_name) + device = cls(HOST, MAC, devtype, name="Bench", model="Test", manufacturer="Test") + framing = "rmmini" if cls_name in {"rmmini", "rmpro", "rm"} else "rmminib" + return device, FakeRM(device, framing) + + +def run(coro): + return asyncio.run(coro) + + +async def press_later(fake: FakeRM, packet: bytes, delay: float) -> bool: + await asyncio.sleep(delay) + return fake.press(packet) + + +FAST = dict(poll_interval=0.01, rearm_interval=10.0) + + +# ------------------------------------------------------------- IR windows + + +@pytest.mark.parametrize("cls_name,devtype", [("rm4pro", 0x649B), ("rmpro", 0x272A), + ("rm4mini", 0x51DA), ("rm5plus", 0x5224)]) +def test_capture_yields_first_signal_and_closes(cls_name, devtype): + device, fake = make(cls_name, devtype) + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, IR, 0.03)) + signals = [s async for s in device.capture(window=2, **FAST)] + return signals + + signals = run(go()) + assert len(signals) == 1 + sig = signals[0] + assert isinstance(sig, CapturedSignal) + assert sig.packet == IR + assert sig.kind is SignalKind.IR + assert sig.pulses == data_to_pulses(IR) + assert sig.frequency_mhz is None + assert fake.commands[0][0] == CMD_LEARN + assert fake.count(CMD_LEARN) == 1 + assert fake.count(CMD_CHECK) >= 2 + assert device._capture_open is False + + +def test_capture_window_elapses_with_nothing(): + device, fake = make() + signals = run(_collect(device.capture(window=0.05, **FAST))) + assert signals == [] + assert fake.count(CMD_LEARN) == 1 + assert fake.count(CMD_CHECK) >= 3 + assert device._capture_open is False + + +async def _collect(gen): + return [s async for s in gen] + + +def test_capture_keeps_going_and_rearms_after_each_code(): + device, fake = make() + + async def go(): + loop = asyncio.get_running_loop() + loop.create_task(press_later(fake, IR, 0.02)) + loop.create_task(press_later(fake, RF, 0.06)) + return [s async for s in device.capture(window=0.12, stop_after_first=False, **FAST)] + + signals = run(go()) + assert [s.packet for s in signals] == [IR, RF] + # Armed once at the start and once after each code. + assert fake.count(CMD_LEARN) == 3 + + +def test_press_between_code_and_rearm_is_lost_but_next_is_not(): + """The device holds one code per session; a second press before the + window re-arms is gone, as measured on the bench.""" + device, fake = make() + + async def go(): + loop = asyncio.get_running_loop() + results = [] + + async def presses(): + await asyncio.sleep(0.02) + results.append(fake.press(IR)) + results.append(fake.press(RF)) # Device not armed: lost. + await asyncio.sleep(0.03) + results.append(fake.press(RF)) # Re-armed by then. + + loop.create_task(presses()) + signals = [s async for s in device.capture(window=0.1, stop_after_first=False, **FAST)] + return results, signals + + results, signals = run(go()) + assert results == [True, False, True] + assert [s.packet for s in signals] == [IR, RF] + + +def test_send_during_window_rearms(): + device, fake = make() + + async def go(): + async def send_then_press(): + await asyncio.sleep(0.02) + await device.send_data(IR) + fake.expire() # Whatever the send did to the session, assume the worst. + await asyncio.sleep(0.03) + return fake.press(RF) + + loop = asyncio.get_running_loop() + task = loop.create_task(send_then_press()) + signals = [s async for s in device.capture(window=0.2, **FAST)] + return await task, signals + + pressed, signals = run(go()) + assert pressed is True + assert [s.packet for s in signals] == [RF] + assert fake.count(CMD_SEND) == 1 + assert fake.count(CMD_LEARN) == 2 + learn_positions = [i for i, (c, _) in enumerate(fake.commands) if c == CMD_LEARN] + send_position = next(i for i, (c, _) in enumerate(fake.commands) if c == CMD_SEND) + assert learn_positions[0] < send_position < learn_positions[1] + + +def test_timed_rearm_recovers_from_silent_expiry(): + device, fake = make() + + async def go(): + async def expire_then_press(): + await asyncio.sleep(0.02) + fake.expire() + assert fake.press(IR) is False # Lost: the device is deaf. + await asyncio.sleep(0.05) # Past the re-arm interval. + return fake.press(IR) + + loop = asyncio.get_running_loop() + task = loop.create_task(expire_then_press()) + signals = [ + s async for s in device.capture(window=0.3, poll_interval=0.01, rearm_interval=0.04) + ] + return await task, signals + + pressed, signals = run(go()) + assert pressed is True + assert len(signals) == 1 + assert fake.count(CMD_LEARN) >= 2 + + +def test_open_ended_window_runs_until_closed(): + device, fake = make() + + async def go(): + got = [] + async with aclosing(device.capture(window=0, stop_after_first=False, **FAST)) as gen: + asyncio.get_running_loop().create_task(press_later(fake, IR, 0.02)) + async for s in gen: + got.append(s) + if len(got) == 1: + break + return got + + got = run(go()) + assert len(got) == 1 + assert device._capture_open is False + # Closing sends nothing further to the device. + assert fake.commands[-1][0] in (CMD_CHECK, CMD_LEARN) + + +def test_second_window_is_refused(): + device, fake = make() + + async def go(): + task = asyncio.get_running_loop().create_task( + _collect(device.capture(window=1, **FAST)) + ) + await asyncio.sleep(0.02) + with pytest.raises(e.CaptureInProgressError): + await _collect(device.capture(window=1, **FAST)) + assert device._capture_open is True + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + run(go()) + assert device._capture_open is False + + +def test_transport_timeouts_rearm_then_give_up(): + device, fake = make() + fake.timeouts_to_raise = 2 + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, IR, 0.05)) + return [s async for s in device.capture(window=1, **FAST)] + + signals = run(go()) + assert len(signals) == 1 + assert fake.count(CMD_LEARN) >= 2 # Re-armed after the timeouts. + + device, fake = make() + fake.timeouts_to_raise = 3 + with pytest.raises(e.NetworkTimeoutError): + run(_collect(device.capture(window=1, **FAST))) + assert device._capture_open is False + + +def test_capture_rejects_bad_arguments(): + device, _ = make() + with pytest.raises(ValueError): + run(_collect(device.capture(window=-1))) + with pytest.raises(ValueError): + run(_collect(device.capture(poll_interval=0))) + with pytest.raises(ValueError): + run(_collect(device.capture(rearm_interval=0))) + + +# ------------------------------------------------------------- RF windows + + +def test_capture_rf_with_known_frequency_skips_the_sweep(): + device, fake = make() + + async def go(): + asyncio.get_running_loop().create_task(press_later(fake, RF, 0.03)) + return [s async for s in device.capture_rf(window=1, frequency=433.92, **FAST)] + + signals = run(go()) + assert len(signals) == 1 + sig = signals[0] + assert sig.kind is SignalKind.RF_433 + assert sig.frequency_mhz == 433.92 + assert sig.packet == RF + assert fake.count(CMD_SWEEP) == 0 + assert fake.commands[0] == (CMD_FIND_RF, struct.pack(" kinds.index(CMD_CHECK_FREQ) + assert fake.commands[find][1] == struct.pack(" 0 diff --git a/tests/test_oracle.py b/tests/test_oracle.py index f5159d62..6cdd4d2b 100644 --- a/tests/test_oracle.py +++ b/tests/test_oracle.py @@ -37,6 +37,9 @@ def test_every_public_method_is_covered() -> None: # 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): From 1439198af15d85cdfc278068c7a6a5ff249f4c4f Mon Sep 17 00:00:00 2001 From: David <128871138+DAB-LABS@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:49:38 -0700 Subject: [PATCH 5/8] Prepare the 1.0.0 release: CLI on capture(), docs, version (#5) - broadlink_cli --learn and --rflearn learn through capture() and capture_rf(), so a session no longer goes deaf when the device times out partway through the 30 s wait. New --window (seconds to listen), --keep (print every code heard) and --repeat (with --send --durations). - cli/README.md: --rfscanlearn was a typo for --rflearn (#803, #830); install line is pip install python-broadlink; examples for --frequency, --window, --keep and --repeat. - README: four device calls in the switch examples were missing await; the RF check_frequency example now unpacks the (found, frequency) tuple. - CHANGELOG: 1.0.0 heading, the CLI changes, and the pulses_to_data bytes-instead-of-bytearray note. - tests/test_capture.py timings expressed as multiples of a 30 ms unit (was 10 ms) for headroom on slower CI runners. - Version 1.0.0. --- CHANGELOG.md | 12 ++++- README.md | 16 +++---- cli/README.md | 26 +++++++++-- cli/broadlink_cli | 105 +++++++++++++++++++----------------------- pyproject.toml | 2 +- tests/test_capture.py | 57 ++++++++++++----------- 6 files changed, 120 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1a750d..b1068bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to this project are recorded here. The format follows Keep a Changelog; versions follow Semantic Versioning. -## Unreleased +## 1.0.0 - 2026-09-05 This is the first release of `python-broadlink`, a maintained fork of `mjg59/python-broadlink` (PyPI `broadlink`, last released as 0.19.0). The @@ -30,7 +30,15 @@ history below starts at that fork point. - Retry and timeout behaviour is unchanged: a request is repeated every second until `timeout` elapses, then `NetworkTimeoutError` is raised. - `dooya.set_percentage_and_wait` sleeps with `asyncio.sleep`. -- The CLI tools run their body under `asyncio.run`. +- The CLI tools run their body under `asyncio.run`. `broadlink_cli + --learn` and `--rflearn` use `capture()` / `capture_rf()`, so a learning + session no longer goes deaf when the device times out partway through; + `--window` sets how long to listen, `--keep` prints every code heard, and + `--send --durations --repeat N` sets the repeat count. The CLI README's + `--rfscanlearn` was a typo for `--rflearn` (mjg59/python-broadlink#803, + #830). +- `pulses_to_data` returns `bytes` (it returned a `bytearray`, against its + own annotation). - Packaging moved to `pyproject.toml`; `setup.py` and the stale `requirements.txt` pin are gone. The distribution name is now `python-broadlink`; the import name stays `broadlink`. Python 3.13 or diff --git a/README.md b/README.md index 6d5d4d5f..3126066d 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ A Python module and CLI for controlling Broadlink devices locally. > upstream [#839](https://github.com/mjg59/python-broadlink/issues/839) > (fix in [#841](https://github.com/mjg59/python-broadlink/pull/841)) and > adds the devices waiting in upstream's pull request queue, including the -> RM Max and RM5 Plus. Version 1.0 will be asynchronous; see `CHANGELOG.md`. -> Upstream's credit and MIT license are preserved. +> 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 @@ -166,9 +166,9 @@ 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 @@ -251,12 +251,12 @@ 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 @@ -269,14 +269,14 @@ 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 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 index 1014986b..9512c7cb 100644 --- a/cli/broadlink_cli +++ b/cli/broadlink_cli @@ -2,13 +2,14 @@ import argparse import asyncio import base64 +import sys import time +from contextlib import 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 @@ -30,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") @@ -51,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") @@ -95,40 +131,17 @@ async def main(): print("{} {}".format(key, data[key])) if args.send: data = ( - pulses_to_data(parse_pulses(args.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): - await dev.enter_learning() print("Learning...") - start = time.time() - while time.time() - start < TIMEOUT: - await asyncio.sleep(1) - try: - data = await dev.check_data() - except (ReadError, StorageError): - continue - else: - break - else: + heard = await listen(dev.capture(window=args.window, stop_after_first=not args.keep)) + if not heard: 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) + sys.exit(1) if args.check: if await dev.check_power(): print('* ON *') @@ -187,7 +200,7 @@ async def main(): else: print("Radiofrequency not found") await dev.cancel_sweep_frequency() - exit(1) + sys.exit(1) print("Radiofrequency detected: {}MHz".format(frequency)) print("You can now let go of the button") @@ -196,34 +209,12 @@ async def main(): print("Press the button again, now a short press.") - await dev.find_rf_packet(frequency) - - start = time.time() - while time.time() - start < TIMEOUT: - await asyncio.sleep(1) - try: - data = await dev.check_data() - except (ReadError, StorageError): - continue - else: - break - else: + heard = await listen( + dev.capture_rf(window=args.window, frequency=frequency, stop_after_first=not args.keep) + ) + if not heard: 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) + sys.exit(1) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 52abb677..76f1a2f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-broadlink" -version = "1.0.0.dev0" +version = "1.0.0" description = "Python API for controlling Broadlink devices" readme = "README.md" license = "MIT" diff --git a/tests/test_capture.py b/tests/test_capture.py index 14ed8c50..c4e4ce33 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -134,7 +134,12 @@ async def press_later(fake: FakeRM, packet: bytes, delay: float) -> bool: return fake.press(packet) -FAST = dict(poll_interval=0.01, rearm_interval=10.0) +# 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 @@ -146,7 +151,7 @@ def test_capture_yields_first_signal_and_closes(cls_name, devtype): device, fake = make(cls_name, devtype) async def go(): - asyncio.get_running_loop().create_task(press_later(fake, IR, 0.03)) + 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 @@ -166,7 +171,7 @@ async def go(): def test_capture_window_elapses_with_nothing(): device, fake = make() - signals = run(_collect(device.capture(window=0.05, **FAST))) + signals = run(_collect(device.capture(window=5 * UNIT, **FAST))) assert signals == [] assert fake.count(CMD_LEARN) == 1 assert fake.count(CMD_CHECK) >= 3 @@ -182,9 +187,9 @@ def test_capture_keeps_going_and_rearms_after_each_code(): async def go(): loop = asyncio.get_running_loop() - loop.create_task(press_later(fake, IR, 0.02)) - loop.create_task(press_later(fake, RF, 0.06)) - return [s async for s in device.capture(window=0.12, stop_after_first=False, **FAST)] + 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] @@ -202,14 +207,14 @@ async def go(): results = [] async def presses(): - await asyncio.sleep(0.02) + await asyncio.sleep(2 * UNIT) results.append(fake.press(IR)) results.append(fake.press(RF)) # Device not armed: lost. - await asyncio.sleep(0.03) + 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=0.1, stop_after_first=False, **FAST)] + signals = [s async for s in device.capture(window=10 * UNIT, stop_after_first=False, **FAST)] return results, signals results, signals = run(go()) @@ -222,15 +227,15 @@ def test_send_during_window_rearms(): async def go(): async def send_then_press(): - await asyncio.sleep(0.02) + 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(0.03) + 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=0.2, **FAST)] + signals = [s async for s in device.capture(window=20 * UNIT, **FAST)] return await task, signals pressed, signals = run(go()) @@ -248,16 +253,16 @@ def test_timed_rearm_recovers_from_silent_expiry(): async def go(): async def expire_then_press(): - await asyncio.sleep(0.02) + await asyncio.sleep(2 * UNIT) fake.expire() assert fake.press(IR) is False # Lost: the device is deaf. - await asyncio.sleep(0.05) # Past the re-arm interval. + 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=0.3, poll_interval=0.01, rearm_interval=0.04) + s async for s in device.capture(window=30 * UNIT, poll_interval=1 * UNIT, rearm_interval=4 * UNIT) ] return await task, signals @@ -273,7 +278,7 @@ def test_open_ended_window_runs_until_closed(): async def go(): got = [] async with aclosing(device.capture(window=0, stop_after_first=False, **FAST)) as gen: - asyncio.get_running_loop().create_task(press_later(fake, IR, 0.02)) + asyncio.get_running_loop().create_task(press_later(fake, IR, 2 * UNIT)) async for s in gen: got.append(s) if len(got) == 1: @@ -294,7 +299,7 @@ async def go(): task = asyncio.get_running_loop().create_task( _collect(device.capture(window=1, **FAST)) ) - await asyncio.sleep(0.02) + await asyncio.sleep(2 * UNIT) with pytest.raises(e.CaptureInProgressError): await _collect(device.capture(window=1, **FAST)) assert device._capture_open is True @@ -311,7 +316,7 @@ def test_transport_timeouts_rearm_then_give_up(): fake.timeouts_to_raise = 2 async def go(): - asyncio.get_running_loop().create_task(press_later(fake, IR, 0.05)) + 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()) @@ -342,7 +347,7 @@ def test_capture_rf_with_known_frequency_skips_the_sweep(): device, fake = make() async def go(): - asyncio.get_running_loop().create_task(press_later(fake, RF, 0.03)) + 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()) @@ -363,7 +368,7 @@ def test_capture_rf_yields_despite_odd_type_byte(): odd = bytes([0xB1]) + RF[1:] async def go(): - asyncio.get_running_loop().create_task(press_later(fake, odd, 0.03)) + asyncio.get_running_loop().create_task(press_later(fake, odd, 3 * UNIT)) return [s async for s in device.capture_rf(window=1, frequency=433.92, **FAST)] signals = run(go()) @@ -377,7 +382,7 @@ def test_capture_rf_below_400mhz_is_tagged_315(): device, fake = make() async def go(): - asyncio.get_running_loop().create_task(press_later(fake, RF, 0.03)) + asyncio.get_running_loop().create_task(press_later(fake, RF, 3 * UNIT)) return [s async for s in device.capture_rf(window=1, frequency=315.0, **FAST)] signals = run(go()) @@ -389,7 +394,7 @@ def test_capture_rf_sweeps_then_learns(): fake.sweep_answers = [(False, 0.0), (False, 0.0), (True, 433.92)] async def go(): - asyncio.get_running_loop().create_task(press_later(fake, RF, 0.08)) + asyncio.get_running_loop().create_task(press_later(fake, RF, 8 * UNIT)) return [s async for s in device.capture_rf(window=1, **FAST)] signals = run(go()) @@ -406,7 +411,7 @@ async def go(): def test_capture_rf_sweep_that_never_locks_is_cancelled(): device, fake = make() - signals = run(_collect(device.capture_rf(window=0.05, **FAST))) + signals = run(_collect(device.capture_rf(window=5 * UNIT, **FAST))) assert signals == [] assert fake.count(CMD_SWEEP) == 1 assert fake.count(CMD_CANCEL_SWEEP) == 1 @@ -421,12 +426,12 @@ def test_send_during_sweep_restarts_it(): async def go(): async def send(): - await asyncio.sleep(0.02) + await asyncio.sleep(2 * UNIT) await device.send_data(IR) loop = asyncio.get_running_loop() loop.create_task(send()) - loop.create_task(press_later(fake, RF, 0.2)) + loop.create_task(press_later(fake, RF, 20 * UNIT)) return [s async for s in device.capture_rf(window=1, **FAST)] signals = run(go()) @@ -442,7 +447,7 @@ async def go(): task = asyncio.get_running_loop().create_task( _collect(device.capture(window=1, **FAST)) ) - await asyncio.sleep(0.02) + await asyncio.sleep(2 * UNIT) with pytest.raises(e.CaptureInProgressError): await _collect(device.capture_rf(window=1, frequency=433.92, **FAST)) with pytest.raises(e.CaptureInProgressError): From ffc7ef8830428cd722fd0e682b2c7d77ae010d00 Mon Sep 17 00:00:00 2001 From: David <128871138+DAB-LABS@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:49:15 -0700 Subject: [PATCH 6/8] Fix the issues found in the review of 1.0.0 (#6) I had 1.0.0 reviewed by an outside party before asking Home Assistant to switch to it. The review confirmed the library talks to devices exactly the way the original did, and found one real bug plus a few small things. This fixes them. No change to the wire format or the public API. Tested on Python 3.13 and 3.14 (252 tests) and live against an RM4 Pro. Technical details: - Replies are matched to their request by the packet counter the device echoes at offset 0x28. A reply for a request that already timed out is dropped; a reply whose counter matches nothing sent is still accepted, so firmware that does not echo the counter keeps working. - Concurrent callers hitting an expired session key share one re-authentication. The logged-out code (-2) now triggers re-auth. - Changing device.host reopens the socket. - aclose() during a request fails it immediately. - capture() treats ReadError (-10) as "nothing yet" alongside StorageError (-5); the CLI inherits this. - An abandoned capture() generator no longer blocks the next one; new capture_active property. - README: broadlink and python-broadlink cannot share an environment. - Changelog: carried-over device commits were squash-merged with Co-authored-by credit; oracle description corrected. - Version 1.0.1. --- CHANGELOG.md | 60 +++++++++++-- README.md | 13 ++- broadlink/device.py | 118 ++++++++++++++++++------- broadlink/remote.py | 185 ++++++++++++++++++++++++++-------------- pyproject.toml | 2 +- tests/test_capture.py | 83 ++++++++++++++++-- tests/test_transport.py | 144 +++++++++++++++++++++++++++++++ 7 files changed, 493 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1068bec..a26eef85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,51 @@ All notable changes to this project are recorded here. The format follows Keep a Changelog; versions follow Semantic Versioning. +## 1.0.1 - 2026-09-05 + +Fixes from an independent review of 1.0.0, most of them in the transport. +None changes the wire format or the public API. + +### Fixed + +- A reply to a request that had already timed out could be delivered as the + reply to the next request on the same device, because the persistent + endpoint (new in 1.0.0) is not thrown away between calls the way the old + per-call socket was. Replies are now matched to their request by the + packet counter the device echoes at offset 0x28; a reply carrying the + counter of a request that already timed out is discarded, and a reply + whose counter matches nothing the device sent is still accepted, so + firmware that does not echo the counter is unaffected. Confirmed on an + RM4 Pro, which echoes it. +- `capture()` treated only `StorageError` (-5) as "nothing captured yet". + Some firmware answers `ReadError` (-10); both are now treated as "nothing + yet", matching what the original CLI and Home Assistant do while polling. + The CLI's `--learn` and `--rflearn` inherit the fix. +- Abandoning a capture generator without closing it (for example `break` + out of `async for` to take one code) no longer blocks the next + `capture()` on the same device: opening a new window closes an abandoned + one. Opening a window while another is actively being iterated still + raises `CaptureInProgressError`. A new read-only `Device.capture_active` + property reports whether a window is open. +- Re-authentication is now shared between concurrent callers: when several + requests hit an expired session key at once, the library authenticates + once and every caller retries, instead of one caller re-authenticating + and the others surfacing the raw error. The logged-out code (-2) now + triggers re-authentication as well, matching Home Assistant's own retry. +- Changing `device.host` after the endpoint is open now reopens it against + the new address instead of continuing to talk to the old one. +- `aclose()` while a request is in flight fails that request at once with + `ConnectionClosedError` instead of waiting out the timeout. + +### Documentation + +- The README explains that `broadlink` and `python-broadlink` install the + same package name and cannot coexist, and how to recover if both were + installed. +- The changelog no longer describes the carried-over device commits as + "intact" (they were squash-merged with `Co-authored-by` credit) and no + longer overstates what the oracle records. + ## 1.0.0 - 2026-09-05 This is the first release of `python-broadlink`, a maintained fork of @@ -39,6 +84,8 @@ history below starts at that fork point. #830). - `pulses_to_data` returns `bytes` (it returned a `bytearray`, against its own annotation). +- The device's request lock is now a private `_lock` that is actually + acquired; the unused public `Device.lock` attribute is gone. - Packaging moved to `pyproject.toml`; `setup.py` and the stale `requirements.txt` pin are gone. The distribution name is now `python-broadlink`; the import name stays `broadlink`. Python 3.13 or @@ -80,7 +127,8 @@ history below starts at that fork point. read by band and a capture is tagged from what it armed rather than the byte. - Devices, carried over from pull requests against the original repository - with their authors' commits intact: RM Max 0xAF8B (#838, Alexey Masolov); + with their authors credited (the changes were squash-merged with + `Co-authored-by` trailers naming each author): RM Max 0xAF8B (#838, Alexey Masolov); RM5 plus 0x5224 with a new `rm5plus` class (#831, Anil Daoud); RM mini 3 OEM 0xA544 (#823, Bartłomiej Nogaś); RM mini 3 CMCC 0x27C8 (#802, shuxin); LB26 R1 0xA517 (#812, techitapart); SP mini 3-AL 0x7D15 (#805, @@ -92,7 +140,9 @@ history below starts at that fork point. issue if either does not behave. - `cryptography` 43 or newer is required, the first release with wheels for Python 3.13 (supersedes mjg59/python-broadlink#749). -- A test suite. The `tests/oracle` package records the exact request bytes - every public method of every device class sends, and the results it - decodes from canned responses, so that later changes to the transport - can be checked byte for byte against the original behavior. +- A test suite. The `tests/oracle` package records, for every public method + of every device class, the request each one hands to the transport (its + packet type and plaintext payload) and the result it decodes from a canned + response, so that a later reimplementation can be checked against the + original method by method; the framing, encryption and checksum layer is + covered separately by `tests/test_transport.py`. diff --git a/README.md b/README.md index 3126066d..8edca5e6 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,16 @@ Use pip3 to install the latest version of this module. pip3 install python-broadlink ``` -If the original `broadlink` distribution is also installed in the same -environment, remove it first (`pip3 uninstall broadlink`); both provide the -`broadlink` package. +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 diff --git a/broadlink/device.py b/broadlink/device.py index 2dc95e0d..4ac3988c 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio +import collections import random import socket from collections.abc import AsyncIterator @@ -30,8 +31,17 @@ HelloResponse = Tuple[int, Tuple[str, int], bytes, str, bool] # Device error codes that mean the session key is no longer accepted and a -# fresh auth() will fix it. -7: control key expired; -4012: control id error. -_REAUTH_CODES = {-7, -4012} +# fresh auth() will fix it. -2: logged out; -7: control key expired; +# -4012: control id error. +_REAUTH_CODES = {-2, -7, -4012} + +# How many timed-out request counters to remember, so that a reply to one +# of them arriving late is recognised and dropped instead of being taken as +# the answer to a later request. +_ABANDONED_MAX = 32 + +_CLOSED = (None, None) +"""Sentinel put on the receive queue when the endpoint is closed.""" class _Protocol(asyncio.DatagramProtocol): @@ -203,7 +213,10 @@ def __init__( self._lock: Optional[asyncio.Lock] = None self._transport: Optional[asyncio.DatagramTransport] = None self._protocol: Optional[_Protocol] = None - self._reauth_ok = True + self._endpoint_addr: Optional[Tuple[str, int]] = None + self._abandoned: collections.deque[int] = collections.deque(maxlen=_ABANDONED_MAX) + self._reauth_lock: Optional[asyncio.Lock] = None + self._auth_generation = 0 def __repr__(self) -> str: """Return a formal representation of the device.""" @@ -275,6 +288,7 @@ async def auth(self) -> bool: self.id = int.from_bytes(payload[:0x4], "little") self.update_aes(payload[0x04:0x14]) + self._auth_generation += 1 return True async def hello(self, local_ip_address=None) -> bool: @@ -363,17 +377,30 @@ def get_type(self) -> str: # -------------------------------------------------------- transport async def aclose(self) -> None: - """Close the device's endpoint. It is reopened on the next call.""" - if self._transport is not None: - self._transport.close() - self._transport = None - self._protocol = None + """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. + """ + transport, protocol = self._transport, self._protocol + self._transport = None + self._protocol = None + self._endpoint_addr = None + if transport is not None: + transport.close() + 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(): self._transport, self._protocol = await _open_endpoint( remote_addr=self.host ) + self._endpoint_addr = self.host return self._transport, self._protocol # type: ignore[return-value] def _frame(self, packet_type: int, payload: bytes) -> bytes: @@ -419,28 +446,53 @@ def _validate(resp: bytes) -> bytes: return resp async def _exchange(self, packet: bytes) -> bytes: - """Send one frame and wait for one reply, resending on silence.""" + """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 a request that already timed out is dropped; one with a counter + this device has never sent 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") while True: transport.sendto(packet) - time_left = timeout - (loop.time() - start) - wait = min(DEFAULT_RETRY_INTVL, time_left) - try: - resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0)) - except asyncio.TimeoutError: - if (loop.time() - start) >= timeout: - raise e.NetworkTimeoutError( - -4000, - "Network timeout", - f"No response received within {timeout}s", - ) from None - continue - return self._validate(resp) + 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 asyncio.TimeoutError: + if loop.time() - start >= timeout: + break + if loop.time() >= resend_at: + break # Resend. + continue + if resp is None: + raise e.ConnectionClosedError( + -4013, "Connection 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._abandoned: + return resp + # A late answer to a request we gave up on: keep waiting. + if loop.time() - start >= timeout: + self._abandoned.append(count) + 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, *, _reauth: bool = True @@ -449,22 +501,24 @@ async def send_packet( 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 self._lock is None: self._lock = asyncio.Lock() + self._reauth_lock = asyncio.Lock() + generation = self._auth_generation async with self._lock: resp = await self._exchange(self._frame(packet_type, bytes(payload))) - if _reauth and self._reauth_ok: + if _reauth: code = int.from_bytes(resp[0x22:0x24], "little", signed=True) if code in _REAUTH_CODES: - self._reauth_ok = False - try: - await self.auth() - async with self._lock: - resp = await self._exchange( - self._frame(packet_type, bytes(payload)) - ) - finally: - self._reauth_ok = True + async with self._reauth_lock: # type: ignore[union-attr] + if self._auth_generation == generation: + await self.auth() + async with self._lock: + resp = await self._exchange( + self._frame(packet_type, bytes(payload)) + ) return resp diff --git a/broadlink/remote.py b/broadlink/remote.py index 9f905618..4527ccc6 100644 --- a/broadlink/remote.py +++ b/broadlink/remote.py @@ -4,6 +4,7 @@ import enum import struct import time +import weakref from dataclasses import dataclass, field from typing import AsyncIterator, Awaitable, Callable, List, Optional, Tuple @@ -214,7 +215,40 @@ def __init__(self, *args, **kwargs) -> None: # against the value it saw when it armed the device and re-arms # after any send, since the device has one front end for both. self._tx_generation = 0 - self._capture_open = False + # Weak reference to the async generator of the current capture + # window, if any. See _claim_window. + self._window: Optional[weakref.ReferenceType] = 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) -> Optional[weakref.ReferenceType]: + """Refuse a new window while another is being iterated; return the + reference to a previous window that the new one should close.""" + old = self._window() if self._window is not None else None + if old is None or old.ag_frame is None: + return None + if old.ag_running: + raise e.CaptureInProgressError("A capture window is already open") + return self._window + + async def _claim_window(self, prev: Optional[weakref.ReferenceType]) -> None: + """Close a previous window whose consumer walked away from it. + + A window whose consumer is still iterating it is live and a new one + is refused at call time (``_check_window``). One the consumer broke + out of without closing the generator is not live; it is closed here + so that it cannot block the device forever. + """ + old = prev() if prev is not None else None + if old is None or old.ag_frame is None: + return + if old.ag_running: + raise e.CaptureInProgressError("A capture window is already open") + await old.aclose() async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a packet to the device.""" @@ -266,10 +300,13 @@ def capture( and after every ``send_data`` on the same device. Closing the generator sends nothing further; the device times out by itself. Use ``contextlib.aclosing`` (or iterate to the end) so the window is - released promptly. Only one capture window can be open per device; - a second raises ``CaptureInProgressError``. + released promptly. Only one capture window can be open per device: + opening one while another is being iterated raises + ``CaptureInProgressError``, and opening one after breaking out of + another without closing it closes the old one. """ - return self._capture_loop( + prev = self._check_window() + gen = self._capture_loop( self.enter_learning, window, stop_after_first, @@ -277,7 +314,10 @@ def capture( rearm_interval, SignalKind.IR, None, + prev=prev, ) + self._window = weakref.ref(gen) + return gen async def _capture_loop( self, @@ -288,58 +328,60 @@ async def _capture_loop( rearm_interval: float, kind: SignalKind, frequency_mhz: Optional[float], + *, + prev: Optional[weakref.ReferenceType] = None, + claim: bool = True, ) -> AsyncIterator[CapturedSignal]: if window < 0: raise ValueError("window must be 0 (open-ended) or positive") if poll_interval <= 0 or rearm_interval <= 0: raise ValueError("poll_interval and rearm_interval must be positive") - if self._capture_open: - raise e.CaptureInProgressError("A capture window is already open") + if claim: + await self._claim_window(prev) - self._capture_open = True - try: - loop = asyncio.get_running_loop() - deadline = loop.time() + window if window else None - timeouts = 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 - await arm() - armed_at = loop.time() - generation = self._tx_generation + while True: + now = loop.time() + if deadline is not None and now >= deadline: + return + delay = poll_interval + if deadline is not None: + delay = min(delay, deadline - now) + await asyncio.sleep(delay) - while True: - now = loop.time() - if deadline is not None and now >= deadline: + 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: + yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind) + if stop_after_first: return - delay = poll_interval - if deadline is not None: - delay = min(delay, deadline - now) - await asyncio.sleep(delay) - - try: - data = await self.check_data() - except e.StorageError: - data = b"" # The device's answer for "nothing yet". - except e.NetworkTimeoutError: - timeouts += 1 - if timeouts >= 3: - raise - generation = -1 # Re-arm; the device's state is unknown. - continue - timeouts = 0 - - if data: - yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind) - if stop_after_first: - return - generation = -1 # One code per session: re-arm. - - now = loop.time() - if generation != self._tx_generation or now - armed_at >= rearm_interval: - await arm() - armed_at = loop.time() - generation = self._tx_generation - finally: - self._capture_open = False + 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 class rmpro(rmmini): @@ -369,7 +411,7 @@ async def cancel_sweep_frequency(self) -> None: """Cancel sweep frequency.""" await self._send(0x1E) - async def capture_rf( + def capture_rf( self, window: float = 30.0, *, @@ -389,24 +431,38 @@ async def capture_rf( The window, polling, re-arm and stop-after-first semantics are those of ``capture``; the sweep counts against the same ``window``. A - ``send_data`` during the sweep restarts it. Each ``CapturedSignal`` - carries the carrier in ``frequency_mhz``, which the packet itself - does not record. + ``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. """ - if self._capture_open: - raise e.CaptureInProgressError("A capture window is already open") + prev = self._check_window() + gen = self._capture_rf_loop( + window, frequency, stop_after_first, poll_interval, rearm_interval, + prev=prev, + ) + self._window = weakref.ref(gen) + return gen + + async def _capture_rf_loop( + self, + window: float, + frequency: Optional[float], + stop_after_first: bool, + poll_interval: float, + rearm_interval: float, + *, + prev: Optional[weakref.ReferenceType], + ) -> AsyncIterator[CapturedSignal]: if window < 0 or poll_interval <= 0: raise ValueError("window must be 0 or positive, poll_interval positive") + await self._claim_window(prev) loop = asyncio.get_running_loop() deadline = loop.time() + window if window else None if frequency is None: - self._capture_open = True - try: - frequency = await self._sweep(deadline, poll_interval) - finally: - self._capture_open = False + frequency = await self._sweep(deadline, poll_interval) if frequency is None: return if deadline is not None: @@ -418,10 +474,15 @@ async def arm() -> None: await self.find_rf_packet(frequency) kind = SignalKind.RF_315 if frequency < 400 else SignalKind.RF_433 - async for signal in self._capture_loop( - arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency - ): - yield signal + inner = self._capture_loop( + arm, window, stop_after_first, poll_interval, rearm_interval, kind, frequency, + claim=False, + ) + try: + async for signal in inner: + yield signal + finally: + await inner.aclose() async def _sweep( self, deadline: Optional[float], poll_interval: float diff --git a/pyproject.toml b/pyproject.toml index 76f1a2f7..c5224d2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-broadlink" -version = "1.0.0" +version = "1.0.1" description = "Python API for controlling Broadlink devices" readme = "README.md" license = "MIT" diff --git a/tests/test_capture.py b/tests/test_capture.py index c4e4ce33..a6381fbf 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -50,6 +50,7 @@ def __init__(self, device: broadlink.Device, framing: str) -> None: self.sweeping = False self.sweep_answers: list[tuple[bool, float]] = [] self.timeouts_to_raise = 0 + self.nothing_yet_code = -5 # -10 on some older firmware device.send_packet = self.send_packet # type: ignore[method-assign] # -- what the test does to the device @@ -98,7 +99,7 @@ def handle(self, command: int, data: bytes) -> tuple[bytes, int | str]: self.timeouts_to_raise -= 1 return b"", "timeout" if self.pending is None: - return b"", -5 + return b"", self.nothing_yet_code code, self.pending = self.pending, None return code, 0 if command == CMD_SEND: @@ -166,7 +167,7 @@ async def go(): assert fake.commands[0][0] == CMD_LEARN assert fake.count(CMD_LEARN) == 1 assert fake.count(CMD_CHECK) >= 2 - assert device._capture_open is False + assert device.capture_active is False def test_capture_window_elapses_with_nothing(): @@ -175,7 +176,7 @@ def test_capture_window_elapses_with_nothing(): assert signals == [] assert fake.count(CMD_LEARN) == 1 assert fake.count(CMD_CHECK) >= 3 - assert device._capture_open is False + assert device.capture_active is False async def _collect(gen): @@ -287,7 +288,7 @@ async def go(): got = run(go()) assert len(got) == 1 - assert device._capture_open is False + assert device.capture_active is False # Closing sends nothing further to the device. assert fake.commands[-1][0] in (CMD_CHECK, CMD_LEARN) @@ -302,13 +303,77 @@ async def go(): await asyncio.sleep(2 * UNIT) with pytest.raises(e.CaptureInProgressError): await _collect(device.capture(window=1, **FAST)) - assert device._capture_open is True + assert device.capture_active is True task.cancel() with pytest.raises(asyncio.CancelledError): await task run(go()) - assert device._capture_open is False + assert device.capture_active is False + + +def test_abandoned_window_is_closed_by_the_next_one(): + """A consumer that breaks out of the loop without closing the generator + must not block the device; the next window closes the old one.""" + 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 s in first: + got = s + break # Walk away without aclose(). + assert device.capture_active is True # The old generator is suspended. + assert not first.ag_running + asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT)) + second = [s async for s in device.capture(window=1, **FAST)] + assert first.ag_frame is None # Closed by the second window. + 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_unstarted_window_does_not_block(): + device, fake = make() + + async def go(): + _unused = device.capture(window=1, **FAST) # 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_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)) + rf = device.capture_rf(window=1, frequency=433.92, stop_after_first=False, **FAST) + async for _ in rf: + break + 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(): @@ -327,7 +392,7 @@ async def go(): fake.timeouts_to_raise = 3 with pytest.raises(e.NetworkTimeoutError): run(_collect(device.capture(window=1, **FAST))) - assert device._capture_open is False + assert device.capture_active is False def test_capture_rejects_bad_arguments(): @@ -417,7 +482,7 @@ def test_capture_rf_sweep_that_never_locks_is_cancelled(): assert fake.count(CMD_CANCEL_SWEEP) == 1 assert fake.count(CMD_FIND_RF) == 0 assert fake.sweeping is False - assert device._capture_open is False + assert device.capture_active is False def test_send_during_sweep_restarts_it(): @@ -457,7 +522,7 @@ async def go(): await task run(go()) - assert device._capture_open is False + assert device.capture_active is False def test_rf_capture_is_only_on_pro_classes(): diff --git a/tests/test_transport.py b/tests/test_transport.py index 37b6447d..f1b59098 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -223,6 +223,89 @@ async def go(): 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_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.ConnectionClosedError): + await task + return asyncio.get_running_loop().time() - t0 + + assert run(go()) < 1.0 + + +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 @@ -311,6 +394,67 @@ async def go(): run(go()) +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 From bc3db2447be19c8b47c70855604117bd68068e77 Mon Sep 17 00:00:00 2001 From: David <128871138+DAB-LABS@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:01:39 -0700 Subject: [PATCH 7/8] Fix the issues found in the second review of 1.0.1 (#7) * Fix the issues found in the second review of 1.0.1 A second, adversarial review of 1.0.1 and a re-test of the first review's findings turned up three real defects in the transport and capture code, none in the wire format. This fixes them and takes the smaller items along. Tested on 3.13 and 3.14, 257 tests, oracle fixtures unchanged. Technical details: - Reply matching remembers every recently used counter, not only timed-out ones, so the second answer to a resent request is dropped instead of being taken as the next request's reply. - auth() resets the session and installs the new key under the request lock, so a queued request is never framed with id 0. - A new capture window gives asyncio's finalizer a turn to close a dropped generator, then refuses if the old window is still alive, instead of taking it from a paused consumer. A refused attempt no longer displaces the live window. - An undecodable returned packet is logged and skipped; the window re-arms. - EndpointClosedError (-4013), a subclass of ConnectionClosedError, for aclose() during a request. - hello() closes the scan generator; TimeoutError spelling; unused protocol future removed; debug logging on device and remote. - README: Closing section, Timing section with the bench numbers, Python support note, return-value differences from 0.19.0. - Version 1.0.2. * Format with ruff and widen the lint rules No behaviour change: 257 tests pass before and after, the oracle fixtures are byte-identical, and repr/str output is unchanged. This is the one-time formatting pass the pyproject comment promised once the async port landed. Technical details: - ruff format over the tree; CI now runs ruff format --check. - Lint set widened from E9/F/I to E, W, F, I, UP, B, ASYNC, RUF. Safe autofixes applied (typing modernised to the 3.13 spellings, f-strings for the percent formatting in repr/str and the exceptions). - Ignored with a reason in pyproject: ASYNC109 (protocol timeout, not a cancellation scope), RUF012 and E721 (inherited class tables and the exception __eq__), RUF006 in tests (helper tasks fired on purpose), and E501 in the three upstream modules with long example payloads. - zip() calls carry an explicit strict=; hello()'s first-reply loop carries a noqa with its reason. --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 52 ++++++ README.md | 75 ++++++-- broadlink/__init__.py | 29 +-- broadlink/alarm.py | 1 + broadlink/climate.py | 37 +--- broadlink/const.py | 1 + broadlink/cover.py | 3 +- broadlink/device.py | 181 +++++++++--------- broadlink/exceptions.py | 18 +- broadlink/helpers.py | 7 +- broadlink/hub.py | 18 +- broadlink/light.py | 64 +++---- broadlink/protocol.py | 1 + broadlink/remote.py | 160 ++++++++++------ broadlink/sensor.py | 3 +- broadlink/switch.py | 64 +++---- pyproject.toml | 21 ++- tests/oracle/cases.py | 395 ++++++++++++++++++++++++++++++++------- tests/oracle/harness.py | 7 +- tests/test_capture.py | 151 ++++++++++++--- tests/test_oracle.py | 12 +- tests/test_remote.py | 3 +- tests/test_transport.py | 103 +++++++++- 24 files changed, 1020 insertions(+), 388 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec1ee0c2..2a14f56e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: pip install -e ".[dev]" - name: Lint run: ruff check . + - name: Format check + run: ruff format --check . - name: Test run: pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index a26eef85..4fcfd7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,58 @@ All notable changes to this project are recorded here. The format follows Keep a Changelog; versions follow Semantic Versioning. +## 1.0.2 - 2026-09-05 + +Fixes from a second, adversarial review of 1.0.1 and a re-test of the +first review's findings. No change to the wire format or the public API. + +### Fixed + +- 1.0.1's reply matching dropped a late reply to a request that had + timed out, but not the second reply to a request that was resent after a + silent second and then answered twice. That duplicate carries the counter + of a request that succeeded, and it could still be taken as the answer + to the next request. The library now remembers every recently used + counter and drops any reply carrying one other than the current + request's. A reply whose counter the device has not used recently is + still accepted, for firmware that may not echo it. +- `auth()` reset the session id and key before taking the request lock, so + a request already queued behind the lock could be framed with device id + 0 and the initial key. The reset, the exchange and the install of the + new key now happen as one unit under the lock. +- 1.0.1 let a new capture window close one that a consumer had abandoned, + using "is the generator running right now" as the test. That cannot + tell an abandoned window from one whose consumer is awaiting something + between signals, which the README's own example does. A new window now + gives asyncio's finalizer one turn to close a genuinely dropped + generator and then refuses if the old window is still alive, rather + than taking it. A refused attempt no longer displaces the live window. +- A packet the device returned that cannot be decoded (a declared length + running into a truncated escape) no longer ends the capture window; it + is logged and the window re-arms. +- `aclose()` during a request now raises `EndpointClosedError`, a subclass + of `ConnectionClosedError` with code -4013 in the error table, so a + caller that closed the device on purpose can tell that apart from the + device's own "logged out" answer. +- `hello()` closes the discovery generator it breaks out of instead of + leaving the socket to the finalizer; `asyncio.TimeoutError` is spelled + `TimeoutError`; an unused future on the protocol object is gone. + +### Added + +- Debug logging on the `broadlink.device` and `broadlink.remote` loggers: + endpoint open and close, resends, dropped late replies, timeouts, + re-authentication, capture arm and re-arm, captured packets. +- README: a "Closing" section on the persistent socket, a "Timing" section + with the bench measurement of the tick fix (5.4 percent short before, + 0.6 percent short after, on an RM4 Pro against an independent + receiver), a note that Python 3.13 is a support decision, and the short + list of return-value differences from 0.19.0. + +### Changed + +- The code is formatted with `ruff format` and CI checks it. + ## 1.0.1 - 2026-09-05 Fixes from an independent review of 1.0.0, most of them in the transport. diff --git a/README.md b/README.md index 8edca5e6..e6231ff1 100644 --- a/README.md +++ b/README.md @@ -16,20 +16,25 @@ A Python module and CLI for controlling Broadlink devices locally. ## Version 1.0 is asynchronous -Every call that reaches a device is a coroutine and must be awaited. This -is the whole change from the original library's API; method names, -arguments and return values are the same. +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, and +`timeout` parameters typed as floats). ```python import asyncio import broadlink + async def main(): devices = await broadlink.discover(timeout=5) device = devices[0] await device.auth() print(await device.check_sensors()) + asyncio.run(main()) ``` @@ -52,8 +57,32 @@ The following devices are supported: - **Thermostats**: Hysen HY02B05H - **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. ``` @@ -94,7 +123,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 -await 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) @@ -103,7 +132,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 -await broadlink.setup('myssid', 'mynetworkpass', 3, ip_address='192.168.0.255') +await broadlink.setup("myssid", "mynetworkpass", 3, ip_address="192.168.0.255") ``` ### Discovery @@ -119,17 +148,17 @@ You may need to specify `local_ip_address` or `discover_ip_address` if discovery Using the IP address of your local machine: ```python3 -devices = await 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 = await 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 = await 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: @@ -144,6 +173,27 @@ After discovering the device, call the `auth()` method to obtain the authenticat await device.auth() ``` +### 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 @@ -175,7 +225,7 @@ await device.sweep_frequency() ```python3 ok, frequency = await device.check_frequency() if ok: - print(f'Frequency found: {frequency} MHz') + print(f"Frequency found: {frequency} MHz") ``` 4. Enter learning mode: ```python3 @@ -217,10 +267,13 @@ By default the window closes after the first signal. Pass `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. +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 correct tick, the +`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. diff --git a/broadlink/__init__.py b/broadlink/__init__.py index eab43e72..632afdf3 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """The python-broadlink library.""" + +import contextlib from collections.abc import AsyncIterator -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from . import exceptions as e from .alarm import S1C @@ -223,8 +225,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: @@ -258,12 +260,15 @@ async def hello( Useful if the device is locked. """ - async for device in xdiscover( - timeout=timeout, - discover_ip_address=ip_address, - discover_ip_port=port, - ): - return device + async with contextlib.aclosing( + xdiscover( + timeout=timeout, + discover_ip_address=ip_address, + discover_ip_port=port, + ) + ) as devices: + async for device in devices: + return device raise e.NetworkTimeoutError( -4000, "Network timeout", @@ -273,10 +278,10 @@ async def hello( async def discover( timeout: float = DEFAULT_TIMEOUT, - local_ip_address: Optional[str] = None, + 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.""" return [ device @@ -288,7 +293,7 @@ async def discover( async def xdiscover( timeout: float = DEFAULT_TIMEOUT, - local_ip_address: Optional[str] = None, + local_ip_address: str | None = None, discover_ip_address: str = DEFAULT_BCAST_ADDR, discover_ip_port: int = DEFAULT_PORT, ) -> AsyncIterator[Device]: diff --git a/broadlink/alarm.py b/broadlink/alarm.py index 2c3358de..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 diff --git a/broadlink/climate.py b/broadlink/climate.py index 5d75457d..6841f87d 100644 --- 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 @@ -33,7 +34,7 @@ async def send_request(self, request: Sequence[int]) -> bytes: 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: @@ -83,9 +84,7 @@ async 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,9 +126,7 @@ async 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 - async 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 await self.send_request([0x01, 0x06, 0x00, 0x02, mode_byte, sensor]) @@ -210,19 +207,7 @@ async def set_power( async def set_time(self, hour: int, minute: int, second: int, day: int) -> None: """Set the time.""" await self.send_request( - [ - 0x01, - 0x10, - 0x00, - 0x08, - 0x00, - 0x02, - 0x04, - hour, - minute, - second, - day - ] + [0x01, 0x10, 0x00, 0x08, 0x00, 0x02, 0x04, hour, minute, second, day] ) # Set timer schedule @@ -231,7 +216,7 @@ async 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) - async 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] @@ -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,7 +324,7 @@ 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] async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a command to the unit.""" 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 0319457a..5c38bd04 100644 --- a/broadlink/cover.py +++ b/broadlink/cover.py @@ -1,6 +1,7 @@ """Support for covers.""" + import asyncio -from typing import Sequence +from collections.abc import Sequence from . import exceptions as e from .device import Device diff --git a/broadlink/device.py b/broadlink/device.py index 4ac3988c..2315f307 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -11,10 +11,11 @@ import asyncio import collections +import contextlib +import logging import random import socket from collections.abc import AsyncIterator -from typing import Optional, Tuple, Union from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -28,17 +29,21 @@ ) from .protocol import Datetime -HelloResponse = Tuple[int, Tuple[str, int], bytes, 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 timed-out request counters to remember, so that a reply to one -# of them arriving late is recognised and dropped instead of being taken as -# the answer to a later request. -_ABANDONED_MAX = 32 +# 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.""" @@ -49,8 +54,7 @@ class _Protocol(asyncio.DatagramProtocol): def __init__(self) -> None: self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue() - self.transport: Optional[asyncio.DatagramTransport] = None - self.closed = asyncio.get_running_loop().create_future() + self.transport: asyncio.DatagramTransport | None = None def connection_made(self, transport) -> None: # type: ignore[override] self.transport = transport @@ -63,9 +67,8 @@ def error_received(self, exc: Exception) -> None: # the retry loop will time out and raise NetworkTimeoutError. pass - def connection_lost(self, exc: Optional[Exception]) -> None: - if not self.closed.done(): - self.closed.set_result(None) + def connection_lost(self, exc: Exception | None) -> None: + pass def drain(self) -> None: """Drop anything that arrived before the current request.""" @@ -74,8 +77,8 @@ def drain(self) -> None: async def _open_endpoint( - local_addr: Optional[tuple[str, int]] = None, - remote_addr: Optional[tuple[str, int]] = None, + 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.""" @@ -111,7 +114,7 @@ def _parse_hello(resp: bytes, host: tuple[str, int]) -> HelloResponse: async def scan( timeout: float = DEFAULT_TIMEOUT, - local_ip_address: Optional[str] = None, + local_ip_address: str | None = None, discover_ip_address: str = DEFAULT_BCAST_ADDR, discover_ip_port: int = DEFAULT_PORT, ) -> AsyncIterator[HelloResponse]: @@ -144,7 +147,7 @@ async def scan( break try: resp, host = await asyncio.wait_for(protocol.queue.get(), remaining) - except asyncio.TimeoutError: + except TimeoutError: break if len(resp) < 0x80: continue @@ -184,8 +187,8 @@ class Device: def __init__( self, - host: Tuple[str, int], - mac: Union[bytes, str], + host: tuple[str, int], + mac: bytes | str, devtype: int, timeout: float = DEFAULT_TIMEOUT, name: str = "", @@ -210,42 +213,32 @@ def __init__( self.aes = None self.update_aes(bytes.fromhex(self.__INIT_KEY)) - self._lock: Optional[asyncio.Lock] = None - self._transport: Optional[asyncio.DatagramTransport] = None - self._protocol: Optional[_Protocol] = None - self._endpoint_addr: Optional[Tuple[str, int]] = None - self._abandoned: collections.deque[int] = collections.deque(maxlen=_ABANDONED_MAX) - self._reauth_lock: Optional[asyncio.Lock] = None + self._lock: asyncio.Lock | None = None + 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 | None = None 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": + async def __aenter__(self) -> Device: return self async def __aexit__(self, *exc) -> None: @@ -272,23 +265,31 @@ def decrypt(self, payload: bytes) -> bytes: # ---------------------------------------------------------- session async def auth(self) -> bool: - """Authenticate to the device.""" - self.id = 0 - self.update_aes(bytes.fromhex(self.__INIT_KEY)) + """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 = await self.send_packet(0x65, packet, _reauth=False) - e.check_error(response[0x22:0x24]) - payload = self.decrypt(response[0x38:]) + packet[0x30:0x36] = b"Test 1" - self.id = int.from_bytes(payload[:0x4], "little") - self.update_aes(payload[0x04:0x14]) - self._auth_generation += 1 + if self._lock is None: + self._lock = asyncio.Lock() + self._reauth_lock = asyncio.Lock() + 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 async def hello(self, local_ip_address=None) -> bool: @@ -296,15 +297,17 @@ async def hello(self, local_ip_address=None) -> bool: 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], - ) entry = None - async for entry in responses: - break + 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, @@ -388,6 +391,7 @@ async def aclose(self) -> 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] @@ -397,10 +401,9 @@ async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]: # old address, so drop it. await self.aclose() if self._transport is None or self._transport.is_closing(): - self._transport, self._protocol = await _open_endpoint( - remote_addr=self.host - ) + self._transport, self._protocol = await _open_endpoint(remote_addr=self.host) 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: @@ -450,8 +453,9 @@ async def _exchange(self, packet: bytes) -> bytes: 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 a request that already timed out is dropped; one with a counter - this device has never sent is accepted, for firmware that may not + 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() @@ -460,9 +464,14 @@ async def _exchange(self, packet: bytes) -> bytes: 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() @@ -471,32 +480,34 @@ async def _exchange(self, packet: bytes) -> bytes: wait = min(resend_at, start + timeout) - now try: resp, _ = await asyncio.wait_for(protocol.queue.get(), max(wait, 0)) - except asyncio.TimeoutError: + except TimeoutError: if loop.time() - start >= timeout: break if loop.time() >= resend_at: break # Resend. continue if resp is None: - raise e.ConnectionClosedError( - -4013, "Connection closed", "The device endpoint was closed" + 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._abandoned: + if reply_count == count or reply_count not in self._recent: return resp - # A late answer to a request we gave up on: keep waiting. + _LOGGER.debug( + "%s: dropped a reply for an earlier request (counter 0x%04x)", + self.host[0], + reply_count, + ) if loop.time() - start >= timeout: - self._abandoned.append(count) + _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, *, _reauth: bool = True - ) -> bytes: + async def send_packet(self, packet_type: int, payload: bytes) -> 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 @@ -511,14 +522,12 @@ async def send_packet( async with self._lock: resp = await self._exchange(self._frame(packet_type, bytes(payload))) - if _reauth: - code = int.from_bytes(resp[0x22:0x24], "little", signed=True) - if code in _REAUTH_CODES: - async with self._reauth_lock: # type: ignore[union-attr] - if self._auth_generation == generation: - await self.auth() - async with self._lock: - 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: # type: ignore[union-attr] + if self._auth_generation == generation: + await self.auth() + 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 8f2ecc6c..f5b56d10 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.""" @@ -142,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"), } diff --git a/broadlink/helpers.py b/broadlink/helpers.py index e7b3d4c9..c41cb84e 100644 --- a/broadlink/helpers.py +++ b/broadlink/helpers.py @@ -1,5 +1,6 @@ """Helper functions and classes.""" -from typing import Dict, List, Sequence + +from collections.abc import Sequence class CRC16: @@ -8,10 +9,10 @@ class CRC16: CRC tables are cached for performance. """ - _cache: Dict[int, List[int]] = {} + _cache: dict[int, list[int]] = {} @classmethod - def get_table(cls, polynomial: int) -> 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 1d74041f..6dd4ed40 100644 --- a/broadlink/hub.py +++ b/broadlink/hub.py @@ -1,7 +1,7 @@ """Support for hubs.""" + import json import struct -from typing import Optional from . import exceptions as e from .device import Device @@ -43,7 +43,7 @@ async def get_subdevices(self, step: int = 5) -> list: return sub_devices - async 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: @@ -56,10 +56,10 @@ async def get_state(self, did: Optional[str] = None) -> dict: 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 = {} @@ -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 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 = {} @@ -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 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 = {} @@ -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(" "SignalKind": def pulses_to_data( - pulses: List[int], + pulses: list[int], tick: float = TICK, *, kind: SignalKind = SignalKind.IR, @@ -103,7 +106,7 @@ def pulses_to_data( return bytes(result) -def data_to_pulses(data: bytes, tick: float = TICK) -> 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 @@ -136,7 +139,7 @@ class ParsedPacket: kind: SignalKind repeat: int - pulses: List[int] + pulses: list[int] type_byte: int @@ -167,19 +170,19 @@ class CapturedSignal: packet: bytes kind: SignalKind - pulses: List[int] = field(repr=False) + pulses: list[int] = field(repr=False) repeat: int = 0 - frequency_mhz: Optional[float] = None - type_byte: Optional[int] = None + 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: Optional[float] = None, + frequency_mhz: float | None = None, *, - kind: Optional[SignalKind] = None, + kind: SignalKind | None = None, ) -> "CapturedSignal": """Build a signal from a device-returned packet. @@ -217,7 +220,7 @@ def __init__(self, *args, **kwargs) -> None: self._tx_generation = 0 # Weak reference to the async generator of the current capture # window, if any. See _claim_window. - self._window: Optional[weakref.ReferenceType] = None + self._window: weakref.ReferenceType | None = None @property def capture_active(self) -> bool: @@ -225,30 +228,38 @@ def capture_active(self) -> bool: 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) -> Optional[weakref.ReferenceType]: - """Refuse a new window while another is being iterated; return the - reference to a previous window that the new one should close.""" + 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 None or old.ag_frame is None: - return None - if old.ag_running: + 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") - return self._window - async def _claim_window(self, prev: Optional[weakref.ReferenceType]) -> None: - """Close a previous window whose consumer walked away from it. + async def _claim_window(self, new: weakref.ReferenceType) -> None: + """Make sure the previous window is really gone, then register ``new``. - A window whose consumer is still iterating it is live and a new one - is refused at call time (``_check_window``). One the consumer broke - out of without closing the generator is not live; it is closed here - so that it cannot block the device forever. + 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. """ - old = prev() if prev is not None else None - if old is None or old.ag_frame is None: - return - if old.ag_running: - raise e.CaptureInProgressError("A capture window is already open") - await old.aclose() + 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" + ) + self._window = new async def _send(self, command: int, data: bytes = b"") -> bytes: """Send a packet to the device.""" @@ -301,11 +312,13 @@ def capture( 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 being iterated raises - ``CaptureInProgressError``, and opening one after breaking out of - another without closing it closes the old one. + 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. """ - prev = self._check_window() + self._check_window() + holder: list = [] gen = self._capture_loop( self.enter_learning, window, @@ -314,9 +327,9 @@ def capture( rearm_interval, SignalKind.IR, None, - prev=prev, + claim=holder, ) - self._window = weakref.ref(gen) + holder.append(weakref.ref(gen)) return gen async def _capture_loop( @@ -327,17 +340,19 @@ async def _capture_loop( poll_interval: float, rearm_interval: float, kind: SignalKind, - frequency_mhz: Optional[float], + frequency_mhz: float | None, *, - prev: Optional[weakref.ReferenceType] = None, - claim: bool = True, + 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(prev) + await self._claim_window(claim[0]) loop = asyncio.get_running_loop() deadline = loop.time() + window if window else None @@ -346,6 +361,7 @@ async def _capture_loop( 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() @@ -372,16 +388,33 @@ async def _capture_loop( timeouts = 0 if data: - yield CapturedSignal.from_packet(data, frequency_mhz, kind=kind) - if stop_after_first: - return - generation = -1 # One code per session: re-arm. + 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): @@ -393,14 +426,14 @@ async def sweep_frequency(self) -> None: """Sweep frequency.""" await self._send(0x19) - async def check_frequency(self) -> Tuple[bool, float]: + async def check_frequency(self) -> tuple[bool, float]: """Return True if the frequency was identified successfully.""" 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: @@ -415,7 +448,7 @@ def capture_rf( self, window: float = 30.0, *, - frequency: Optional[float] = None, + frequency: float | None = None, stop_after_first: bool = True, poll_interval: float = DEFAULT_POLL_INTERVAL, rearm_interval: float = DEFAULT_REARM_INTERVAL, @@ -436,27 +469,32 @@ def capture_rf( Each ``CapturedSignal`` carries the carrier in ``frequency_mhz``, which the packet itself does not record. """ - prev = self._check_window() + self._check_window() + holder: list = [] gen = self._capture_rf_loop( - window, frequency, stop_after_first, poll_interval, rearm_interval, - prev=prev, + window, + frequency, + stop_after_first, + poll_interval, + rearm_interval, + claim=holder, ) - self._window = weakref.ref(gen) + holder.append(weakref.ref(gen)) return gen async def _capture_rf_loop( self, window: float, - frequency: Optional[float], + frequency: float | None, stop_after_first: bool, poll_interval: float, rearm_interval: float, *, - prev: Optional[weakref.ReferenceType], + 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(prev) + await self._claim_window(claim[0]) loop = asyncio.get_running_loop() deadline = loop.time() + window if window else None @@ -475,8 +513,14 @@ async def arm() -> None: 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=False, + arm, + window, + stop_after_first, + poll_interval, + rearm_interval, + kind, + frequency, + claim=None, ) try: async for signal in inner: @@ -484,9 +528,7 @@ async def arm() -> None: finally: await inner.aclose() - async def _sweep( - self, deadline: Optional[float], poll_interval: float - ) -> Optional[float]: + 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() @@ -532,7 +574,7 @@ async def _send(self, command: int, data: bytes = b"") -> bytes: e.check_error(resp[0x22:0x24]) payload = self.decrypt(resp[0x38:]) p_len = struct.unpack(" None: 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 = {} @@ -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: e.check_error(response[0x22:0x24]) payload = self.decrypt(response[0x38:]) js_len = struct.unpack_from(" dict: 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 = {} @@ -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 = {} @@ -459,8 +459,8 @@ async def get_state(self) -> dict: 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/pyproject.toml b/pyproject.toml index c5224d2e..cde5452c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-broadlink" -version = "1.0.1" +version = "1.0.2" description = "Python API for controlling Broadlink devices" readme = "README.md" license = "MIT" @@ -57,10 +57,23 @@ line-length = 90 target-version = "py313" [tool.ruff.lint] -# Start from the upstream flake8 gate (syntax errors and undefined names) -# plus pyflakes and import hygiene. Style rules widen once the async port lands. -select = ["E9", "F", "I"] +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/tests/oracle/cases.py b/tests/oracle/cases.py index df782acb..df265748 100644 --- a/tests/oracle/cases.py +++ b/tests/oracle/cases.py @@ -44,7 +44,9 @@ def rmminib_payload(body: bytes) -> str: def hysen_payload(body: bytes) -> str: """hysen.send_request: [len][body][crc16(body)]; returns body.""" p_len = len(body) + 2 - return hexb(struct.pack(" str: @@ -120,7 +122,9 @@ def sensor(status, order, stype, name, serial): 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[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 @@ -145,13 +149,13 @@ def hysen_status_body() -> bytes: def hvac_state_data() -> bytes: data = bytearray(2 + 13) s = memoryview(data)[2:] - s[0x00] = (int(24) - 8 << 3) | 2 # target 24, swing_v POS2 + s[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 + s[0x0A] = 1 << 4 # display return bytes(data) @@ -212,30 +216,63 @@ def all_cases() -> list[dict]: # 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_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, + "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)): + 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"])) + 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]))])) + 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]) @@ -243,15 +280,23 @@ def all_cases() -> list[dict]: 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)): + 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]: add(case(cls, devtype, "set_power", True, responses=[EMPTY])) add(case(cls, devtype, "check_power", responses=[on])) add(case(cls, devtype, "check_power", responses=[off])) - add(case("sp2s", 0x2728, "get_energy", - responses=["00000000" + (1234).to_bytes(3, "little").hex() + "00" * 9])) - add(case("sp3s", 0x947A, "get_energy", - responses=["0000000000" + "341200" + "00" * 8])) # bytes 5..7 = 34 12 00 + add( + case( + "sp2s", + 0x2728, + "get_energy", + responses=["00000000" + (1234).to_bytes(3, "little").hex() + "00" * 9], + ) + ) + add( + case("sp3s", 0x947A, "get_energy", responses=["0000000000" + "341200" + "00" * 8]) + ) # bytes 5..7 = 34 12 00 nl_on = "00000000" + "03" + "00" * 11 # power bit0, nightlight bit1 add(case("sp3", 0x753E, "set_power", True, responses=[nl_on, EMPTY])) add(case("sp3", 0x753E, "set_nightlight", True, responses=[on, EMPTY])) add(case("sp3", 0x753E, "check_power", responses=[nl_on])) add(case("sp3", 0x753E, "check_nightlight", responses=[nl_on])) - sp4_state = {"pwr": 1, "ntlight": 0, "indicator": 1, "ntlbrightness": 50, - "maxworktime": 0, "childlock": 0} + sp4_state = { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0, + } add(case("sp4", 0x7579, "get_state", responses=[json12_payload(sp4_state)])) add(case("sp4", 0x7579, "set_power", True, responses=[json12_payload(sp4_state)])) - add(case("sp4", 0x7579, "set_nightlight", False, responses=[json12_payload(sp4_state)])) - add(case("sp4", 0x7579, "set_state", pwr=True, ntlbrightness=25, childlock=True, - responses=[json12_payload(sp4_state)])) + add( + case( + "sp4", 0x7579, "set_nightlight", False, responses=[json12_payload(sp4_state)] + ) + ) + add( + case( + "sp4", + 0x7579, + "set_state", + pwr=True, + ntlbrightness=25, + childlock=True, + responses=[json12_payload(sp4_state)], + ) + ) add(case("sp4", 0x7579, "check_power", responses=[json12_payload(sp4_state)])) add(case("sp4", 0x7579, "check_nightlight", responses=[json12_payload(sp4_state)])) - sp4b_state = dict(sp4_state, current=120, volt=230500, power=27600, - totalconsum=-1, overload=0) + sp4b_state = dict( + sp4_state, current=120, volt=230500, power=27600, totalconsum=-1, overload=0 + ) add(case("sp4b", 0x5115, "get_state", responses=[json14_payload(sp4b_state)])) - add(case("sp4b", 0x5115, "set_state", pwr=False, responses=[json14_payload(sp4b_state)])) + add( + case( + "sp4b", 0x5115, "set_state", pwr=False, responses=[json14_payload(sp4b_state)] + ) + ) add(case("sp4b", 0x5115, "check_power", responses=[json14_payload(sp4b_state)])) - bg_state = {"pwr": 1, "pwr1": 1, "pwr2": 0, "maxworktime": 60, "maxworktime1": 60, - "maxworktime2": 0, "idcbrightness": 50} + bg_state = { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50, + } add(case("bg1", 0x51E3, "get_state", responses=[json14_payload(bg_state)])) - add(case("bg1", 0x51E3, "set_state", pwr1=True, maxworktime2=15, - responses=[json14_payload(bg_state)])) + add( + case( + "bg1", + 0x51E3, + "set_state", + pwr1=True, + maxworktime2=15, + responses=[json14_payload(bg_state)], + ) + ) add(case("ehc31", 0x6480, "get_state", responses=[json14_payload(bg_state)])) - add(case("ehc31", 0x6480, "set_state", pwr3=True, childlock=True, childlock4=False, - responses=[json14_payload(bg_state)])) + add( + case( + "ehc31", + 0x6480, + "set_state", + pwr3=True, + childlock=True, + childlock4=False, + responses=[json14_payload(bg_state)], + ) + ) add(case("mp1", 0x4EB5, "set_power_mask", 0b0101, True, responses=[EMPTY])) add(case("mp1", 0x4EB5, "set_power", 1, True, responses=[EMPTY])) @@ -310,42 +410,105 @@ def all_cases() -> list[dict]: # Sensors ----------------------------------------------------------- add(case("a1", 0x2714, "check_sensors", responses=[a1_payload()])) - add(case("a1", 0x2714, "check_sensors", responses=[a1_payload(light=9, air=9, noise=9)])) + add( + case( + "a1", 0x2714, "check_sensors", responses=[a1_payload(light=9, air=9, noise=9)] + ) + ) add(case("a1", 0x2714, "check_sensors_raw", responses=[a1_payload()])) add(case("a2", 0x4F60, "check_sensors_raw", responses=[a2_payload()])) # Lights ------------------------------------------------------------ - lb_state = {"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} + lb_state = { + "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, + } add(case("lb1", 0x60C7, "get_state", responses=[json14_payload(lb_state)])) - add(case("lb1", 0x60C7, "set_state", pwr=True, brightness=50, bulb_colormode=1, - bulb_scene="", responses=[json14_payload(lb_state)])) + add( + case( + "lb1", + 0x60C7, + "set_state", + pwr=True, + brightness=50, + bulb_colormode=1, + bulb_scene="", + responses=[json14_payload(lb_state)], + ) + ) add(case("lb2", 0xA4F4, "get_state", responses=[json12_payload(lb_state)])) - add(case("lb2", 0xA4F4, "set_state", pwr=False, red=1, green=2, blue=3, - transitionduration=200, responses=[json12_payload(lb_state)])) + add( + case( + "lb2", + 0xA4F4, + "set_state", + pwr=False, + red=1, + green=2, + blue=3, + transitionduration=200, + responses=[json12_payload(lb_state)], + ) + ) # Climate ----------------------------------------------------------- status = hysen_payload(hysen_status_body()) ack = hysen_payload(bytes([0x01, 0x06, 0x00, 0x02, 0x21, 0x00])) - add(case("hysen", 0x4EAD, "send_request", [0x01, 0x03, 0x00, 0x00, 0x00, 0x08], - responses=[status])) + add( + case( + "hysen", + 0x4EAD, + "send_request", + [0x01, 0x03, 0x00, 0x00, 0x00, 0x08], + responses=[status], + ) + ) add(case("hysen", 0x4EAD, "get_temp", responses=[status])) add(case("hysen", 0x4EAD, "get_external_temp", responses=[status])) add(case("hysen", 0x4EAD, "get_full_status", responses=[status])) add(case("hysen", 0x4EAD, "set_mode", 1, 2, responses=[ack])) add(case("hysen", 0x4EAD, "set_mode", 0, 0, 1, responses=[ack])) - add(case("hysen", 0x4EAD, "set_advanced", 0, 0, 42, 2, 35, 5, -0.5, 0, 1, - responses=[ack])) + add( + case( + "hysen", + 0x4EAD, + "set_advanced", + 0, + 0, + 42, + 2, + 35, + 5, + -0.5, + 0, + 1, + responses=[ack], + ) + ) add(case("hysen", 0x4EAD, "switch_to_auto", responses=[ack])) add(case("hysen", 0x4EAD, "switch_to_manual", responses=[ack])) add(case("hysen", 0x4EAD, "set_temp", 21.5, responses=[ack])) add(case("hysen", 0x4EAD, "set_power", 1, 0, 1, responses=[ack])) add(case("hysen", 0x4EAD, "set_time", 14, 30, 5, 3, responses=[ack])) - sched_wd = [{"start_hour": 6 + i, "start_minute": 15, "temp": 20 + i} for i in range(6)] - sched_we = [{"start_hour": 8, "start_minute": 0, "temp": 21}, - {"start_hour": 22, "start_minute": 30, "temp": 17.5}] + sched_wd = [ + {"start_hour": 6 + i, "start_minute": 15, "temp": 20 + i} for i in range(6) + ] + sched_we = [ + {"start_hour": 8, "start_minute": 0, "temp": 21}, + {"start_hour": 22, "start_minute": 30, "temp": 17.5}, + ] add(case("hysen", 0x4EAD, "set_schedule", sched_wd, sched_we, responses=[ack])) # A corrupted CRC must be rejected. bad = bytearray.fromhex(status) @@ -355,12 +518,69 @@ def all_cases() -> list[dict]: add(case("hvac", 0x4E2A, "get_state", responses=[hvac_payload(hvac_state_data())])) add(case("hvac", 0x4E2A, "get_ac_info", responses=[hvac_payload(hvac_info_data())])) add(case("hvac", 0x4E2A, "get_state", responses=[hvac_payload(b"\x00\x00\x01")])) - add(case("hvac", 0x4E2A, "set_state", True, 22.5, 1, 2, 0, 7, 0, False, False, True, - False, False, False, responses=[hvac_payload(hvac_state_data())])) - add(case("hvac", 0x4E2A, "set_state", True, 24, 4, 3, 2, 0, 0, False, False, True, - False, False, False, responses=[hvac_payload(hvac_state_data())])) - add(case("hvac", 0x4E2A, "set_state", True, 24, 2, 1, 1, 0, 0, False, False, True, - False, False, False, responses=[hvac_payload(hvac_state_data())])) + add( + case( + "hvac", + 0x4E2A, + "set_state", + True, + 22.5, + 1, + 2, + 0, + 7, + 0, + False, + False, + True, + False, + False, + False, + responses=[hvac_payload(hvac_state_data())], + ) + ) + add( + case( + "hvac", + 0x4E2A, + "set_state", + True, + 24, + 4, + 3, + 2, + 0, + 0, + False, + False, + True, + False, + False, + False, + responses=[hvac_payload(hvac_state_data())], + ) + ) + add( + case( + "hvac", + 0x4E2A, + "set_state", + True, + 24, + 2, + 1, + 1, + 0, + 0, + False, + False, + True, + False, + False, + False, + responses=[hvac_payload(hvac_state_data())], + ) + ) # Covers ------------------------------------------------------------ pos = "00000000" + "32" + "00" * 11 # payload[4] = 50 @@ -379,12 +599,29 @@ def all_cases() -> list[dict]: # Hub and alarm ----------------------------------------------------- subs1 = {"total": 3, "list": [{"did": "a1", "pwr1": 1}, {"did": "a2", "pwr1": 0}]} subs2 = {"total": 3, "list": [{"did": "a2", "pwr1": 0}, {"did": "a3", "pwr1": 1}]} - add(case("s3", 0xA59C, "get_subdevices", 2, - responses=[json12_payload(subs1), json12_payload(subs2)])) + add( + case( + "s3", + 0xA59C, + "get_subdevices", + 2, + responses=[json12_payload(subs1), json12_payload(subs2)], + ) + ) add(case("s3", 0xA59C, "get_state", responses=[json12_payload({"pwr1": 1})])) add(case("s3", 0xA59C, "get_state", "a1", responses=[json12_payload({"pwr1": 1})])) - add(case("s3", 0xA59C, "set_state", "a1", True, None, False, - responses=[json12_payload({"pwr1": 1, "pwr3": 0})])) + add( + case( + "s3", + 0xA59C, + "set_state", + "a1", + True, + None, + False, + responses=[json12_payload({"pwr1": 1, "pwr3": 0})], + ) + ) add(case("S1C", 0x2722, "get_sensors_status", responses=[s1c_payload()])) # Error path shared by every class: a non-zero device error code. @@ -395,8 +632,22 @@ def all_cases() -> list[dict]: def error_cases() -> 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}, + { + "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/harness.py b/tests/oracle/harness.py index 9fb8d2d0..8c5c5254 100644 --- a/tests/oracle/harness.py +++ b/tests/oracle/harness.py @@ -72,8 +72,7 @@ def __call__(self, packet_type: int, payload: bytes) -> bytes: self.sent.append((packet_type, bytes(payload))) if not self.responses: raise AssertionError( - f"method sent more packets than canned responses " - f"({len(self.sent)} sent)" + f"method sent more packets than canned responses ({len(self.sent)} sent)" ) return make_response(self.device, self.responses.pop(0), self.error) @@ -116,7 +115,7 @@ def run_case(case: dict) -> dict: responses = [bytes.fromhex(r) for r in case.get("responses", [])] recorder = Recorder(device, responses, case.get("error_code", 0)) - target = getattr(device, "send_packet") + target = device.send_packet if inspect.iscoroutinefunction(target): device.send_packet = recorder.async_call # type: ignore[method-assign] else: @@ -132,7 +131,7 @@ def run_case(case: dict) -> dict: if inspect.isawaitable(result): result = asyncio.run(_await(result)) outcome["result"] = normalize(result) - except Exception as err: # noqa: BLE001 - the error type IS the oracle + except Exception as err: outcome["error"] = f"{type(err).__name__}: {err}" outcome["sent"] = [[ptype, payload.hex()] for ptype, payload in recorder.sent] diff --git a/tests/test_capture.py b/tests/test_capture.py index a6381fbf..82389f69 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -111,7 +111,9 @@ def handle(self, command: int, data: bytes) -> tuple[bytes, int | str]: self.sweeping = False return b"", 0 if command == CMD_CHECK_FREQ: - found, freq = self.sweep_answers.pop(0) if self.sweep_answers else (False, 0.0) + found, freq = ( + self.sweep_answers.pop(0) if self.sweep_answers else (False, 0.0) + ) return bytes([found]) + struct.pack(" 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]: +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" @@ -146,8 +150,10 @@ async def press_later(fake: FakeRM, packet: bytes, delay: float) -> bool: # ------------------------------------------------------------- IR windows -@pytest.mark.parametrize("cls_name,devtype", [("rm4pro", 0x649B), ("rmpro", 0x272A), - ("rm4mini", 0x51DA), ("rm5plus", 0x5224)]) +@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) @@ -190,7 +196,12 @@ 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)] + 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] @@ -215,7 +226,12 @@ async def presses(): 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)] + signals = [ + s + async for s in device.capture( + window=10 * UNIT, stop_after_first=False, **FAST + ) + ] return results, signals results, signals = run(go()) @@ -263,7 +279,10 @@ async def expire_then_press(): 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) + s + async for s in device.capture( + window=30 * UNIT, poll_interval=1 * UNIT, rearm_interval=4 * UNIT + ) ] return await task, signals @@ -278,7 +297,9 @@ def test_open_ended_window_runs_until_closed(): async def go(): got = [] - async with aclosing(device.capture(window=0, stop_after_first=False, **FAST)) as gen: + 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) @@ -294,7 +315,7 @@ async def go(): def test_second_window_is_refused(): - device, fake = make() + device, _fake = make() async def go(): task = asyncio.get_running_loop().create_task( @@ -312,22 +333,20 @@ async def go(): assert device.capture_active is False -def test_abandoned_window_is_closed_by_the_next_one(): - """A consumer that breaks out of the loop without closing the generator - must not block the device; the next window closes the old one.""" +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)) - first = device.capture(window=1, stop_after_first=False, **FAST) - async for s in first: + got = None + async for s in device.capture(window=1, stop_after_first=False, **FAST): got = s - break # Walk away without aclose(). - assert device.capture_active is True # The old generator is suspended. - assert not first.ag_running + 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)] - assert first.ag_frame is None # Closed by the second window. return got, second got, second = run(go()) @@ -336,17 +355,100 @@ async def go(): assert device.capture_active is False -def test_unstarted_window_does_not_block(): +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(): - _unused = device.capture(window=1, **FAST) # never iterated + 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 @@ -365,9 +467,10 @@ def test_capture_rf_abandoned_then_ir_window(): async def go(): asyncio.get_running_loop().create_task(press_later(fake, RF, 2 * UNIT)) - rf = device.capture_rf(window=1, frequency=433.92, stop_after_first=False, **FAST) - async for _ in rf: - break + 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)] @@ -506,7 +609,7 @@ async def send(): def test_capture_rf_refused_while_ir_window_open(): - device, fake = make() + device, _fake = make() async def go(): task = asyncio.get_running_loop().create_task( diff --git a/tests/test_oracle.py b/tests/test_oracle.py index 6cdd4d2b..c0a1accb 100644 --- a/tests/test_oracle.py +++ b/tests/test_oracle.py @@ -35,8 +35,16 @@ def test_every_public_method_is_covered() -> None: 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"} + 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"} diff --git a/tests/test_remote.py b/tests/test_remote.py index c0f7f06a..72cd02ef 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -1,4 +1,5 @@ """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 @@ -34,7 +35,7 @@ def test_round_trip(self): pulses = [9000, 4500, 560, 1690, 560, 560] packet = pulses_to_data(pulses) decoded = data_to_pulses(packet) - for original, result in zip(pulses, decoded): + 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): diff --git a/tests/test_transport.py b/tests/test_transport.py index f1b59098..414bb044 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -62,7 +62,9 @@ def __init__(self): 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) + transport = FakeTransport( + protocol, local_addr, remote_addr, broadcast, self.replies + ) self.replies = [] protocol.connection_made(transport) self.endpoints.append(transport) @@ -262,6 +264,97 @@ async def answer_later(): 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() @@ -284,8 +377,10 @@ async def go(): await asyncio.sleep(0.01) t0 = asyncio.get_running_loop().time() await dev.aclose() - with pytest.raises(e.ConnectionClosedError): + 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 @@ -424,7 +519,9 @@ def sendto(data, addr=None): 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")) + 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()) From 2fbcca50e6f1ccf6c1633f1bbd19ecb14fe807ca Mon Sep 17 00:00:00 2001 From: DAB-LABS <128871138+DAB-LABS@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:06:21 +0000 Subject: [PATCH 8/8] Fix the issues found in the review of 1.0.2 A third review, this one of 1.0.2, found one behaviour that was worse than the original library's and a handful of small things. Nothing in the wire format changed. This fixes them. Tested on 3.13 and 3.14, 261 tests, oracle fixtures unchanged, and live against an RM4 Pro under -X dev. Technical details: - When re-authentication after an expired-key answer fails (a device locked in the app, say), the request's own reply is returned, so the caller sees the same AuthorizationError or ConnectionClosedError the original library raised, not an AuthenticationError from the retry. The failure is logged at debug. - The auth generation is read under the request lock, so a request queued behind auth() cannot observe a stale one and skip a needed re-auth. - aclose() racing an endpoint open no longer leaks the new socket; the open notices the close and raises EndpointClosedError. - A new capture window re-checks for a rival claimant after giving the finalizer its turn. - CapturedSignal.pulses and ParsedPacket.pulses are tuples, so the frozen dataclasses are hashable. - check_error unpacks with "> 8 # Checksum 2 position - transport, _ = await _open_endpoint(broadcast=True) - try: - transport.sendto(payload, (ip_address, DEFAULT_PORT)) - finally: - transport.close() + await send_setup_packet(bytes(payload), ip_address) diff --git a/broadlink/device.py b/broadlink/device.py index 2315f307..600356ea 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -56,10 +56,12 @@ 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) -> None: # type: ignore[override] - self.transport = transport + 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: @@ -68,7 +70,7 @@ def error_received(self, exc: Exception) -> None: pass def connection_lost(self, exc: Exception | None) -> None: - pass + """Nothing to do; a waiting request is told through the queue.""" def drain(self) -> None: """Drop anything that arrived before the current request.""" @@ -161,6 +163,17 @@ async def scan( transport.close() +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: + transport.close() + + async def ping(ip_address: str, port: int = DEFAULT_PORT) -> None: """Send a ping packet to an address. @@ -213,12 +226,13 @@ def __init__( self.aes = None self.update_aes(bytes.fromhex(self.__INIT_KEY)) - self._lock: asyncio.Lock | None = None + 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 | None = None + 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: @@ -277,9 +291,6 @@ async def auth(self) -> bool: packet[0x2D] = 0x01 packet[0x30:0x36] = b"Test 1" - if self._lock is None: - self._lock = asyncio.Lock() - self._reauth_lock = asyncio.Lock() async with self._lock: self.id = 0 self.update_aes(bytes.fromhex(self.__INIT_KEY)) @@ -292,7 +303,7 @@ async def auth(self) -> bool: _LOGGER.debug("%s: authenticated, session id %d", self.host[0], self.id) return True - async 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. @@ -385,6 +396,7 @@ async def aclose(self) -> None: 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 @@ -401,7 +413,15 @@ async def _endpoint(self) -> tuple[asyncio.DatagramTransport, _Protocol]: # old address, so drop it. await self.aclose() if self._transport is None or self._transport.is_closing(): - self._transport, self._protocol = await _open_endpoint(remote_addr=self.host) + 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] @@ -507,27 +527,33 @@ async def _exchange(self, packet: bytes) -> bytes: f"No response received within {timeout}s", ) from None - async def send_packet(self, packet_type: int, payload: bytes) -> bytes: + 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. + 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. """ - if self._lock is None: - self._lock = asyncio.Lock() - self._reauth_lock = asyncio.Lock() - generation = self._auth_generation 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: # type: ignore[union-attr] + async with self._reauth_lock: if self._auth_generation == generation: - await self.auth() + 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 f5b56d10..7437d6e8 100644 --- a/broadlink/exceptions.py +++ b/broadlink/exceptions.py @@ -168,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(" bool: + """True for the radio bands.""" return self is not SignalKind.IR @classmethod - def classify(cls, type_byte: int) -> "SignalKind": + 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_ @@ -139,7 +141,7 @@ class ParsedPacket: kind: SignalKind repeat: int - pulses: list[int] + pulses: tuple[int, ...] type_byte: int @@ -152,7 +154,7 @@ def parse_packet(data: bytes, tick: float = TICK) -> ParsedPacket: if len(data) < 4: raise ValueError("Malformed data.") kind = SignalKind.classify(data[0x00]) - return ParsedPacket(kind, data[0x01], data_to_pulses(data, tick), data[0x00]) + return ParsedPacket(kind, data[0x01], tuple(data_to_pulses(data, tick)), data[0x00]) @dataclass(frozen=True) @@ -170,7 +172,7 @@ class CapturedSignal: packet: bytes kind: SignalKind - pulses: list[int] = field(repr=False) + pulses: tuple[int, ...] = field(repr=False) repeat: int = 0 frequency_mhz: float | None = None type_byte: int | None = None @@ -183,7 +185,7 @@ def from_packet( frequency_mhz: float | None = None, *, kind: SignalKind | None = None, - ) -> "CapturedSignal": + ) -> Self: """Build a signal from a device-returned packet. ``kind`` overrides the band read from the packet's type byte. A @@ -200,7 +202,7 @@ def from_packet( return cls( bytes(packet), kind, - data_to_pulses(packet), + tuple(data_to_pulses(packet)), packet[0x01], frequency_mhz, type_byte, @@ -259,6 +261,9 @@ async def _claim_window(self, new: weakref.ReferenceType) -> 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: diff --git a/cli/broadlink_cli b/cli/broadlink_cli index 9512c7cb..7884f289 100644 --- a/cli/broadlink_cli +++ b/cli/broadlink_cli @@ -4,7 +4,7 @@ import asyncio import base64 import sys import time -from contextlib import aclosing +from contextlib import AsyncExitStack, aclosing from typing import List import broadlink @@ -96,6 +96,12 @@ args = parser.parse_args() 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: @@ -110,6 +116,7 @@ async def main(): 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: diff --git a/pyproject.toml b/pyproject.toml index cde5452c..af17ec80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-broadlink" -version = "1.0.2" +version = "1.0.3" description = "Python API for controlling Broadlink devices" readme = "README.md" license = "MIT" diff --git a/tests/test_capture.py b/tests/test_capture.py index 82389f69..222c4ecd 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -168,7 +168,7 @@ async def go(): assert isinstance(sig, CapturedSignal) assert sig.packet == IR assert sig.kind is SignalKind.IR - assert sig.pulses == data_to_pulses(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 @@ -661,7 +661,7 @@ def test_parse_packet_round_trip(): parsed = parse_packet(packet) assert parsed.kind is kind assert parsed.repeat == 1 - assert parsed.pulses == data_to_pulses(packet) + assert parsed.pulses == tuple(data_to_pulses(packet)) for a, b in zip(pulses, parsed.pulses, strict=True): assert abs(a - b) <= 16 @@ -712,10 +712,16 @@ def test_signal_kind_flags(): assert SignalKind(0x26) is SignalKind.IR +def test_captured_signal_is_hashable(): + a = CapturedSignal.from_packet(RF, 433.92) + assert isinstance(hash(a), int) # a frozen value type belongs in a set + assert len({parse_packet(RF), parse_packet(RF)}) == 1 + + def test_captured_signal_from_packet(): sig = CapturedSignal.from_packet(RF, 433.92) assert sig.kind is SignalKind.RF_433 assert sig.repeat == 0 - assert sig.pulses == data_to_pulses(RF) + assert sig.pulses == tuple(data_to_pulses(RF)) assert sig.frequency_mhz == 433.92 assert sig.captured_at > 0 diff --git a/tests/test_transport.py b/tests/test_transport.py index 414bb044..770a45e0 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -75,7 +75,6 @@ async def __call__(self, local_addr=None, remote_addr=None, broadcast=False): def net(monkeypatch): fake = FakeNet() monkeypatch.setattr(device_module, "_open_endpoint", fake) - monkeypatch.setattr(broadlink, "_open_endpoint", fake) # Keep the retry loop from waiting on real time. monkeypatch.setattr(device_module, "DEFAULT_RETRY_INTVL", 0.005) return fake @@ -386,6 +385,30 @@ async def go(): 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() @@ -475,18 +498,100 @@ async def go(): assert dev.decrypt(resp[0x38:])[0] == 9 -def test_reauth_is_not_attempted_twice(net): +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, auth fails - return await dev.send_packet(0x6A, b"") + 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): - run(go()) + 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):