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