From 08aeb79196d60dc154e3b5ba3f7a0288ceebe8d9 Mon Sep 17 00:00:00 2001 From: DAB-LABS <128871138+DAB-LABS@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:50:51 -0700 Subject: [PATCH] Add packaging, CI, publishing workflow and a byte-level test suite Move packaging to pyproject.toml as the python-broadlink distribution (import name unchanged), require Python 3.13 or newer, and replace the flake8 workflow with ruff and pytest on 3.13 and 3.14 plus an sdist and wheel build. Add a trusted-publishing workflow for version tags. Add tests/oracle: a harness that records the exact request bytes every public method of every device class sends and the result it decodes from canned responses, frozen in fixtures.json (155 cases), plus transport tests for send_packet framing, checksums, auth, discovery, gendevice and setup, and tests for the pure helpers. No library behavior changes; three import blocks were reordered for ruff. Add a README note explaining the fork and a CHANGELOG. --- .github/PULL_REQUEST_TEMPLATE.md | 24 +- .github/workflows/ci.yml | 49 + .github/workflows/flake8.yaml | 33 - .github/workflows/publish.yml | 46 + .gitignore | 11 + CHANGELOG.md | 27 + MANIFEST.in | 5 + README.md | 22 +- broadlink/__init__.py | 2 +- broadlink/device.py | 2 +- broadlink/hub.py | 2 +- pyproject.toml | 66 + requirements.txt | 1 - setup.py | 29 - tests/__init__.py | 0 tests/oracle/__init__.py | 1 + tests/oracle/cases.py | 402 +++ tests/oracle/fixtures.json | 4099 ++++++++++++++++++++++++++++++ tests/oracle/harness.py | 158 ++ tests/oracle/record.py | 38 + tests/test_helpers.py | 93 + tests/test_oracle.py | 54 + tests/test_transport.py | 383 +++ 23 files changed, 5463 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/flake8.yaml create mode 100644 .github/workflows/publish.yml create mode 100644 CHANGELOG.md create mode 100644 MANIFEST.in create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/oracle/__init__.py create mode 100644 tests/oracle/cases.py create mode 100644 tests/oracle/fixtures.json create mode 100644 tests/oracle/harness.py create mode 100644 tests/oracle/record.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_oracle.py create mode 100644 tests/test_transport.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index de98d3df..19057e2c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,10 +1,8 @@ ## Context - [ ] Dependency upgrade @@ -41,15 +38,10 @@ - This PR fixes issue: fixes # - This PR is related to: -- Link to documentation pull request: ## Checklist - -- [ ] The code change is tested and works locally. -- [ ] The code has been formatted using Black. -- [ ] The code follows the [Zen of Python](https://www.python.org/dev/peps/pep-0020/). -- [ ] I am creating the Pull Request against the correct branch. -- [ ] Documentation added/updated. +- [ ] The code change is tested and works locally (`pytest`). +- [ ] `ruff check .` passes. +- [ ] New device support was verified on real hardware, or the PR says it was not. +- [ ] `CHANGELOG.md` has an entry under Unreleased. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ec1ee0c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Test + run: pytest + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build + python -m build + - name: Check the wheel imports + run: | + python -m venv /tmp/check + /tmp/check/bin/pip install dist/*.whl + /tmp/check/bin/python -c "import broadlink; print(broadlink.__name__)" + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/flake8.yaml b/.github/workflows/flake8.yaml deleted file mode 100644 index aa09a19c..00000000 --- a/.github/workflows/flake8.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Python flake8 - -on: - push: - branches: [ master, dev ] - pull_request: - branches: [ master, dev ] - -jobs: - test: - runs-on: ubuntu-20.04 - strategy: - matrix: - python-version: [3.6, 3.7, 3.8, 3.9] - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install wheel - pip install flake8 flake8-quotes - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. ignore magic numbers and use double quotes and ignore numbers with zeroes before them. - # and ignore lowercase hex numbers and ignore isort incorrect imports - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=90 --ignore=WPS432,WPS339,WPS341,I --inline-quotes double --statistics diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..833ab776 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: Publish to PyPI + +# Runs on a version tag (v1.0.0, v1.0.1, ...). Uses PyPI trusted publishing: +# the project on PyPI is configured to trust this repository, this workflow +# file name, and the "pypi" environment. No API token is stored anywhere. + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Check the tag matches the package version + run: | + TAG="${GITHUB_REF_NAME#v}" + VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + echo "tag=$TAG version=$VERSION" + test "$TAG" = "$VERSION" + - name: Build + run: | + python -m pip install --upgrade pip build + python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 0d20b648..ef2edb2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,12 @@ *.pyc +__pycache__/ +*.egg-info/ +build/ +dist/ +.venv/ +.pytest_cache/ +.ruff_cache/ +.DS_Store + +# Working notes that are not part of the published project. +docs/internal/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6ef4e048 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project are recorded here. The format follows +Keep a Changelog; versions follow Semantic Versioning. + +## Unreleased + +This is the first release of `python-broadlink`, a maintained fork of +`mjg59/python-broadlink` (PyPI `broadlink`, last released as 0.19.0). The +history below starts at that fork point. + +### Changed + +- Packaging moved to `pyproject.toml`; `setup.py` and the stale + `requirements.txt` pin are gone. The distribution name is now + `python-broadlink`; the import name stays `broadlink`. Python 3.13 or + newer is required. +- Continuous integration now runs `ruff` and `pytest` on Python 3.13 and + 3.14, and builds the sdist and wheel on every pull request. Releases are + published to PyPI from version tags using trusted publishing. + +### Added + +- A test suite. The `tests/oracle` package records the exact request bytes + every public method of every device class sends, and the results it + decodes from canned responses, so that later changes to the transport + can be checked byte for byte against the original behavior. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..2422af09 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include LICENSE README.md CHANGELOG.md protocol.md TROUBLESHOOTING.md +include pyproject.toml +graft cli +graft tests +global-exclude __pycache__ *.py[cod] .DS_Store diff --git a/README.md b/README.md index 81c6de5b..34a82386 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ # python-broadlink -A Python module and CLI for controlling Broadlink devices locally. The following devices are supported: +A Python module and CLI for controlling Broadlink devices locally. + +> **About this fork.** This repository is a maintained fork of +> [mjg59/python-broadlink](https://github.com/mjg59/python-broadlink), which +> has not accepted changes since 2024. It exists so that Home Assistant's +> Broadlink integration has a library that can take fixes and new devices. +> The distribution on PyPI is `python-broadlink`; the import name stays +> `broadlink`. The first release corrects the IR timing constant reported in +> upstream [#839](https://github.com/mjg59/python-broadlink/issues/839) +> (fix in [#841](https://github.com/mjg59/python-broadlink/pull/841)) and +> adds the devices waiting in upstream's pull request queue, including the +> RM Max and RM5 Plus. Version 1.0 will be asynchronous; see `CHANGELOG.md`. +> Upstream's credit and MIT license are preserved. + +The following devices are supported: - **Universal remotes**: RM home, RM mini 3, RM plus, RM pro, RM pro+, RM4 mini, RM4 pro, RM4C mini, RM4S, RM4 TV mate - **Smart plugs**: SP mini, SP mini 3, SP mini+, SP1, SP2, SP2-BR, SP2-CL, SP2-IN, SP2-UK, SP3, SP3-EU, SP3S-EU, SP3S-US, SP4L-AU, SP4L-EU, SP4L-UK, SP4M, SP4M-US, Ankuoo NEO, Ankuoo NEO PRO, Efergy Ego, BG AHC/U-01 @@ -19,9 +33,13 @@ A Python module and CLI for controlling Broadlink devices locally. The following Use pip3 to install the latest version of this module. ``` -pip3 install broadlink +pip3 install python-broadlink ``` +If the original `broadlink` distribution is also installed in the same +environment, remove it first (`pip3 uninstall broadlink`); both provide the +`broadlink` package. + ## Basic functions First, open Python 3 and import this module. diff --git a/broadlink/__init__.py b/broadlink/__init__.py index d3135501..b2fa3d9a 100644 --- a/broadlink/__init__.py +++ b/broadlink/__init__.py @@ -4,9 +4,9 @@ from typing import Generator, List, Optional, Tuple, Union from . import exceptions as e -from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT from .alarm import S1C from .climate import hvac, hysen +from .const import DEFAULT_BCAST_ADDR, DEFAULT_PORT, DEFAULT_TIMEOUT from .cover import dooya, dooya2, wser from .device import Device, ping, scan from .hub import s3 diff --git a/broadlink/device.py b/broadlink/device.py index 5a10bc01..22c3ebed 100644 --- a/broadlink/device.py +++ b/broadlink/device.py @@ -1,7 +1,7 @@ """Support for Broadlink devices.""" +import random import socket import threading -import random import time from typing import Generator, Optional, Tuple, Union diff --git a/broadlink/hub.py b/broadlink/hub.py index 0fd4ae53..40dd8e2d 100644 --- a/broadlink/hub.py +++ b/broadlink/hub.py @@ -1,6 +1,6 @@ """Support for hubs.""" -import struct import json +import struct from typing import Optional from . import exceptions as e diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7c223b33 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-broadlink" +version = "1.0.0.dev0" +description = "Python API for controlling Broadlink devices" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.13" +authors = [ + { name = "Matthew Garrett", email = "mjg59@srcf.ucam.org" }, + { name = "DAB-LABS" }, +] +maintainers = [ + { name = "DAB-LABS" }, +] +keywords = ["broadlink", "infrared", "rf", "home-assistant", "rm4", "rm-pro"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Home Automation", +] +dependencies = [ + "cryptography>=3.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "ruff>=0.5", + "build", +] + +[project.urls] +Homepage = "https://github.com/DAB-LABS/python-broadlink" +Repository = "https://github.com/DAB-LABS/python-broadlink" +Issues = "https://github.com/DAB-LABS/python-broadlink/issues" +Changelog = "https://github.com/DAB-LABS/python-broadlink/blob/master/CHANGELOG.md" +Upstream = "https://github.com/mjg59/python-broadlink" + +[tool.setuptools] +packages = ["broadlink"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 90 +target-version = "py313" + +[tool.ruff.lint] +# Start from the upstream flake8 gate (syntax errors and undefined names) +# plus pyflakes and import hygiene. Style rules widen once the async port lands. +select = ["E9", "F", "I"] + +[tool.ruff.lint.per-file-ignores] +# The package __init__ re-exports the public API. +"broadlink/__init__.py" = ["F401"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2c6c996c..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -cryptography==3.2 diff --git a/setup.py b/setup.py deleted file mode 100644 index 0426f148..00000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -from setuptools import setup, find_packages - - -version = '0.19.0' - -setup( - name="broadlink", - version=version, - author="Matthew Garrett", - author_email="mjg59@srcf.ucam.org", - url="http://github.com/mjg59/python-broadlink", - packages=find_packages(), - scripts=[], - install_requires=["cryptography>=3.2"], - description="Python API for controlling Broadlink devices", - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - ], - include_package_data=True, - zip_safe=False, -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/oracle/__init__.py b/tests/oracle/__init__.py new file mode 100644 index 00000000..40616ad4 --- /dev/null +++ b/tests/oracle/__init__.py @@ -0,0 +1 @@ +"""Byte-level oracle for the device classes. See harness.py.""" diff --git a/tests/oracle/cases.py b/tests/oracle/cases.py new file mode 100644 index 00000000..df782acb --- /dev/null +++ b/tests/oracle/cases.py @@ -0,0 +1,402 @@ +"""The oracle cases: one entry per public method per device class. + +Each case names a device class and product id, a method with arguments, and +the canned response payloads (plaintext, before encryption) the fake device +answers with, in order. ``record.py`` runs them against the library and +freezes the outcome in ``fixtures.json``; ``test_oracle.py`` replays them +and compares. + +Canned payloads are shaped for each class's decoder so the method exercises +its full parse path. Comments say which bytes each decoder reads. +""" + +from __future__ import annotations + +import json +import struct + +from broadlink.helpers import CRC16 + + +def hexb(*parts: bytes | bytearray) -> str: + return b"".join(bytes(p) for p in parts).hex() + + +def b(value: bytes | bytearray) -> dict: + """Wrap bytes for JSON storage.""" + return {"__bytes__": bytes(value).hex()} + + +# ---------------------------------------------------------------- payload builders + + +def rmmini_payload(body: bytes) -> str: + """rmmini._send returns payload[4:]; the first four bytes echo the command.""" + return hexb(b"\x01\x00\x00\x00", body) + + +def rmminib_payload(body: bytes) -> str: + """rmminib._send reads p_len at [0:2] and returns payload[6:p_len+2].""" + p_len = len(body) + 4 + return hexb(struct.pack(" str: + """hysen.send_request: [len][body][crc16(body)]; returns body.""" + p_len = len(body) + 2 + return hexb(struct.pack(" str: + """hvac._decode: [len][bb 00 07 00 00 00][d_len][data][crc16 poly 0x9BE4].""" + p_len = 10 + len(data) + head = struct.pack(" str: + """12-byte header: js_len at 0x08, JSON at 0x0C (sp4, lb2, s3).""" + data = json.dumps(state, separators=(",", ":")).encode() + head = struct.pack(" str: + """14-byte header: js_len at 0x0A, JSON at 0x0E (sp4b, bg1, lb1).""" + data = json.dumps(state, separators=(",", ":")).encode() + head = struct.pack(" str: + p = bytearray(0x10) + p[0x04], p[0x05] = temp + p[0x06], p[0x07] = hum + p[0x08] = light + p[0x0A] = air + p[0x0C] = noise + return p.hex() + + +def a2_payload() -> str: + p = bytearray(0x18) + p[0x0D:0x0F] = (12).to_bytes(2, "big") # pm10 + p[0x0F:0x11] = (7).to_bytes(2, "big") # pm2_5 + p[0x11:0x13] = (3).to_bytes(2, "big") # pm1 + p[0x13:0x15] = (235).to_bytes(2, "big") # temperature + p[0x15:0x17] = (452).to_bytes(2, "big") # humidity + return p.hex() + + +def mp1s_payload() -> str: + """mp1s.get_state slices payload.hex()[4:-6] and reads BCD digit pairs.""" + digits = "".join(str(i % 10) for i in range(54)) + return "0000" + digits + "000000" + + +def s1c_payload() -> str: + def sensor(status, order, stype, name, serial): + s = bytearray(83) + s[0] = status + s[1] = order + s[3] = stype + s[4 : 4 + len(name)] = name.encode() + s[26:30] = serial + return bytes(s) + + p = bytearray(6) + p[4] = 2 + return hexb( + p, + sensor(1, 1, 0x31, "Front door", b"\x01\x02\x03\x04"), + sensor(0, 2, 0x21, "Hall", b"\x0a\x0b\x0c\x0d"), + sensor(0, 3, 0x91, "", b"\x00\x00\x00\x00"), # empty serial: filtered out + ) + + +def hysen_status_body() -> bytes: + body = bytearray(48) + body[3] = 0x01 # remote_lock + body[4] = 0b1101_0001 # heating_cooling=1, temp_manual=1, active=1, offset add=0, power=1 + body[5] = 43 # room temp 21.5 + body[6] = 44 # thermostat temp 22.0 + body[7] = 0x21 # loop_mode 2, auto_mode 1 + body[8] = 0 # sensor + body[9] = 42 # osv + body[10] = 2 # dif + body[11] = 35 # svh + body[12] = 5 # svl + body[13:15] = (-5).to_bytes(2, "big", signed=True) # room_temp_adj -0.5 + body[15] = 0 # fre + body[16] = 1 # poweron + body[17] = 0x20 # unknown (offset raw 2) + body[18] = 50 # external temp 25.0 + body[19], body[20], body[21], body[22] = 14, 30, 5, 3 + for i in range(8): + body[2 * i + 23] = 6 + i + body[2 * i + 24] = 15 + body[i + 39] = 40 + i + return bytes(body) + + +def hvac_state_data() -> bytes: + data = bytearray(2 + 13) + s = memoryview(data)[2:] + s[0x00] = (int(24) - 8 << 3) | 2 # target 24, swing_v POS2 + s[0x01] = (7 << 5) | 0b100 # swing_h OFF + s[0x03] = 2 << 5 # speed MID + s[0x04] = 1 << 6 # preset TURBO (bits 6-7; bit 7 doubles as the half degree) + s[0x05] = (1 << 5) | (1 << 2) # mode COOL, sleep + s[0x08] = (1 << 5) | (1 << 2) | 0b11 # power, clean, health + s[0x0A] = (1 << 4) # display + return bytes(data) + + +def hvac_info_data() -> bytes: + data = bytearray(2 + 22) + s = memoryview(data)[2:] + s[0x01] = 1 + s[0x05] = 26 + s[0x15] = 5 + return bytes(data) + + +def fw_payload(version: int) -> str: + p = bytearray(8) + p[4:6] = version.to_bytes(2, "little") + return p.hex() + + +def rm_update_payload(name: str, locked: bool) -> str: + body = bytearray(0x88) + body[0x48 : 0x48 + len(name)] = name.encode() + body[0x87] = int(locked) + return rmmini_payload(bytes(body)) + + +def rmminib_update_payload(name: str, locked: bool) -> str: + body = bytearray(0x88) + body[0x48 : 0x48 + len(name)] = name.encode() + body[0x87] = int(locked) + return rmminib_payload(bytes(body)) + + +IR_CODE = bytes.fromhex("2600180012341234123412340d05") +EMPTY = "00" * 16 + +# ------------------------------------------------------------------------ cases + + +def case(cls, devtype, method, *args, responses=(), attrs=(), setup=None, **kwargs): + entry = { + "cls": cls, + "devtype": devtype, + "method": method, + "args": list(args), + "kwargs": kwargs, + "responses": list(responses), + } + if attrs: + entry["attrs"] = list(attrs) + if setup: + entry["setup"] = setup + return entry + + +def all_cases() -> list[dict]: + cases: list[dict] = [] + add = cases.append + + # Device base ------------------------------------------------------- + add(case("Device", 0x0000, "get_fwversion", responses=[fw_payload(0x1234)])) + add(case("Device", 0x0000, "set_name", "Living room", responses=[EMPTY], attrs=["name"])) + add(case("Device", 0x0000, "set_lock", True, responses=[EMPTY], attrs=["is_locked"])) + add(case("Device", 0x0000, "set_lock", False, responses=[EMPTY], attrs=["is_locked"], + setup={"name": "Kitchen"})) + add(case("Device", 0x0000, "get_type")) + + # RM family --------------------------------------------------------- + for cls, devtype, payload in (("rmmini", 0x2737, rmmini_payload), + ("rmpro", 0x272A, rmmini_payload), + ("rmminib", 0x5F36, rmminib_payload), + ("rm4mini", 0x51DA, rmminib_payload), + ("rm4pro", 0x6026, rmminib_payload), + ("rm", 0x2712, rmmini_payload), + ("rm4", 0x62BE, rmminib_payload)): + add(case(cls, devtype, "send_data", b(IR_CODE), responses=[payload(b"")])) + add(case(cls, devtype, "enter_learning", responses=[payload(b"")])) + add(case(cls, devtype, "check_data", responses=[payload(IR_CODE)])) + upd = rmminib_update_payload if payload is rmminib_payload else rm_update_payload + add(case(cls, devtype, "update", responses=[upd("Bedroom RM", True)], + attrs=["name", "is_locked"])) + + for cls, devtype in (("rmpro", 0x272A), ("rm", 0x2712)): + add(case(cls, devtype, "check_sensors", responses=[rmmini_payload(bytes([23, 4]))])) + add(case(cls, devtype, "check_temperature", responses=[rmmini_payload(bytes([23, 4]))])) + + for cls, devtype in (("rm4mini", 0x51DA), ("rm4pro", 0x6026), ("rm4", 0x62BE)): + body = bytes([24, 35, 51, 20]) + add(case(cls, devtype, "check_sensors", responses=[rmminib_payload(body)])) + add(case(cls, devtype, "check_temperature", responses=[rmminib_payload(body)])) + add(case(cls, devtype, "check_humidity", responses=[rmminib_payload(body)])) + + for cls, devtype, payload in (("rmpro", 0x272A, rmmini_payload), + ("rm4pro", 0x6026, rmminib_payload), + ("rm", 0x2712, rmmini_payload), + ("rm4", 0x62BE, rmminib_payload)): + add(case(cls, devtype, "sweep_frequency", responses=[payload(b"")])) + found = bytes([1]) + struct.pack(" list[dict]: + """Cases whose canned response carries a device error code.""" + return [ + {"cls": "rmmini", "devtype": 0x2737, "method": "enter_learning", + "args": [], "kwargs": {}, "responses": [EMPTY], "error_code": 0xFFFB}, + {"cls": "sp2", "devtype": 0x2711, "method": "check_power", + "args": [], "kwargs": {}, "responses": [EMPTY], "error_code": 0xFFF9}, + ] diff --git a/tests/oracle/fixtures.json b/tests/oracle/fixtures.json new file mode 100644 index 00000000..782b2bae --- /dev/null +++ b/tests/oracle/fixtures.json @@ -0,0 +1,4099 @@ +[ + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "get_fwversion", + "args": [], + "kwargs": {}, + "responses": [ + "0000000034120000" + ] + }, + "expect": { + "result": 4660, + "sent": [ + [ + 106, + "68" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_name", + "args": [ + "Living room" + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "name" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "000000004c6976696e6720726f6f6d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Living room" + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_lock", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0000000042656e63680000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "is_locked": true + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "set_lock", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "attrs": [ + "is_locked" + ], + "setup": { + "name": "Kitchen" + } + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "000000004b69746368656e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + ], + "unused_responses": 0, + "attrs": { + "is_locked": false + } + } + }, + { + "case": { + "cls": "Device", + "devtype": 0, + "method": "get_type", + "args": [], + "kwargs": {}, + "responses": [] + }, + "expect": { + "result": "Unknown", + "sent": [], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmminib", + "devtype": 24374, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "010000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d050000000000000000000000000000" + }, + "sent": [ + [ + 106, + "04000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "send_data", + "args": [ + { + "__bytes__": "2600180012341234123412340d05" + } + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1200020000002600180012341234123412340d05" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040003000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [ + "1200000000002600180012341234123412340d05" + ] + }, + "expect": { + "result": { + "__bytes__": "2600180012341234123412340d05" + }, + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "update", + "args": [], + "kwargs": {}, + "responses": [ + "8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426564726f6f6d20524d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + ], + "attrs": [ + "name", + "is_locked" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040001000000" + ] + ], + "unused_responses": 0, + "attrs": { + "name": "Bedroom RM", + "is_locked": true + } + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": { + "temperature": 23.4 + }, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": 23.4, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": { + "temperature": 23.4 + }, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "010000001704" + ] + }, + "expect": { + "result": 23.4, + "sent": [ + [ + 106, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": { + "temperature": 24.35, + "humidity": 51.2 + }, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_temperature", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 24.35, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_humidity", + "args": [], + "kwargs": {}, + "responses": [ + "08000000000018233314" + ] + }, + "expect": { + "result": 51.2, + "sent": [ + [ + 106, + "040024000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "19000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0100000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "010000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmpro", + "devtype": 10026, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040019000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "09000000000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0900000000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08001b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4pro", + "devtype": 24614, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "19000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0100000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "010000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "1a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm", + "devtype": 10002, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "01000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "1e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "040019000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "09000000000001009f0600" + ] + }, + "expect": { + "result": [ + true, + 433.92 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "check_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "0900000000000000000000" + ] + }, + "expect": { + "result": [ + false, + 0.0 + ], + "sent": [ + [ + 106, + "04001a000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "find_rf_packet", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001b000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "find_rf_packet", + "args": [ + 433.92 + ], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08001b000000009f0600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4", + "devtype": 25278, + "method": "cancel_sweep_frequency", + "args": [], + "kwargs": {}, + "responses": [ + "040000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "04001e000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp1", + "devtype": 0, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 102, + "01000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp1", + "devtype": 0, + "method": "set_power", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 102, + "00000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "02000000010000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "02000000010000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2s", + "devtype": 10024, + "method": "get_energy", + "args": [], + "kwargs": {}, + "responses": [ + "00000000d20400000000000000000000" + ] + }, + "expect": { + "result": 1.234, + "sent": [ + [ + 106, + "04000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3s", + "devtype": 38010, + "method": "get_energy", + "args": [], + "kwargs": {}, + "responses": [ + "00000000003412000000000000000000" + ] + }, + "expect": { + "result": 12.34, + "sent": [ + [ + 106, + "0800fe0105010000002d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000", + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ], + [ + 106, + "02000000030000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "set_nightlight", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "00000000010000000000000000000000", + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ], + [ + 106, + "02000000030000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp3", + "devtype": 30014, + "method": "check_nightlight", + "args": [], + "kwargs": {}, + "responses": [ + "00000000030000000000000000000000" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_power", + "args": [ + true + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "a5a55a5ac3c3020b090000007b22707772223a317d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_nightlight", + "args": [ + false + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "a5a55a5a67c5020b0d0000007b226e746c69676874223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": true, + "ntlbrightness": 25, + "childlock": true + }, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0 + }, + "sent": [ + [ + 106, + "a5a55a5a04cf020b2a0000007b22707772223a312c226e746c6272696768746e657373223a32352c226368696c646c6f636b223a317d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4", + "devtype": 30073, + "method": "check_nightlight", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b540000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a307d" + ] + }, + "expect": { + "result": false, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0, + "current": 0.12, + "volt": 230.5, + "power": 27.6, + "overload": 0.0 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": false + }, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "ntlight": 0, + "indicator": 1, + "ntlbrightness": 50, + "maxworktime": 0, + "childlock": 0, + "current": 120, + "volt": 230500, + "power": 27600, + "totalconsum": -1, + "overload": 0 + }, + "sent": [ + [ + 106, + "1500a5a55a5ac2c3020b090000007b22707772223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp4b", + "devtype": 20757, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "a800a5a55a5a0000010b9c0000007b22707772223a312c226e746c69676874223a302c22696e64696361746f72223a312c226e746c6272696768746e657373223a35302c226d6178776f726b74696d65223a302c226368696c646c6f636b223a302c2263757272656e74223a3132302c22766f6c74223a3233303530302c22706f776572223a32373630302c22746f74616c636f6e73756d223a2d312c226f7665726c6f6164223a307d" + ] + }, + "expect": { + "result": true, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "bg1", + "devtype": 20963, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "bg1", + "devtype": 20963, + "method": "set_state", + "args": [], + "kwargs": { + "pwr1": true, + "maxworktime2": 15 + }, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "2b00a5a55a5a64ca020b1f0000007b2270777231223a20312c20226d6178776f726b74696d6532223a2031357d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "ehc31", + "devtype": 25728, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "ehc31", + "devtype": 25728, + "method": "set_state", + "args": [], + "kwargs": { + "pwr3": true, + "childlock": true, + "childlock4": false + }, + "responses": [ + "6e00a5a55a5a0000010b620000007b22707772223a312c2270777231223a312c2270777232223a302c226d6178776f726b74696d65223a36302c226d6178776f726b74696d6531223a36302c226d6178776f726b74696d6532223a302c226964636272696768746e657373223a35307d" + ] + }, + "expect": { + "result": { + "pwr": 1, + "pwr1": 1, + "pwr2": 0, + "maxworktime": 60, + "maxworktime1": 60, + "maxworktime2": 0, + "idcbrightness": 50 + }, + "sent": [ + [ + 106, + "3800a5a55a5afccd020b2c0000007b2270777233223a20312c20226368696c646c6f636b223a20312c20226368696c646c6f636b34223a20307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power_mask", + "args": [ + 5, + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5abcc00200030000050500" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power", + "args": [ + 1, + true + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5ab4c00200030000010100" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "set_power", + "args": [ + 3, + false + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00a5a55a5ab6c00200030000040000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "check_power_raw", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": 11, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1", + "devtype": 20149, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": { + "s1": true, + "s2": true, + "s3": false, + "s4": true + }, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1s", + "devtype": 20251, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "0000012345678901234567890123456789012345678901234567890123000000" + ] + }, + "expect": { + "result": { + "volt": 230.1, + "current": 89.6745, + "power": 4523.01, + "totalconsum": 230189.67 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab2c00100040000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "mp1s", + "devtype": 20251, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000b00" + ] + }, + "expect": { + "result": { + "s1": true, + "s2": true, + "s3": false, + "s4": true + }, + "sent": [ + [ + 106, + "0a00a5a55a5aaec00100000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020200010000000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": "normal", + "air_quality": "good", + "noise": "quiet" + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020900090009000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": "unknown", + "air_quality": "unknown", + "noise": "unknown" + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a1", + "devtype": 10004, + "method": "check_sensors_raw", + "args": [], + "kwargs": {}, + "responses": [ + "0000000017052d020200010000000000" + ] + }, + "expect": { + "result": { + "temperature": 23.5, + "humidity": 45.2, + "light": 2, + "air_quality": 1, + "noise": 0 + }, + "sent": [ + [ + 106, + "01" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "a2", + "devtype": 20320, + "method": "check_sensors_raw", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000c0007000300eb01c400" + ] + }, + "expect": { + "result": { + "temperature": 235, + "humidity": 452, + "pm10": 12, + "pm2_5": 7, + "pm1": 3 + }, + "sent": [ + [ + 106, + "0a00a5a55a5ab9c0010b0000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb1", + "devtype": 24775, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "e500a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "0e00a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb1", + "devtype": 24775, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": true, + "brightness": 50, + "bulb_colormode": 1, + "bulb_scene": "" + }, + "responses": [ + "e500a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "4800a5a55a5ae1d4020b3c0000007b22707772223a312c226272696768746e657373223a35302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e65223a22227d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb2", + "devtype": 42228, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "lb2", + "devtype": 42228, + "method": "set_state", + "args": [], + "kwargs": { + "pwr": false, + "red": 1, + "green": 2, + "blue": 3, + "transitionduration": 200 + }, + "responses": [ + "a5a55a5a0000010bd90000007b22726564223a3132382c22626c7565223a3235352c22677265656e223a3132382c22707772223a312c226272696768746e657373223a37352c22636f6c6f7274656d70223a323730302c22687565223a3234302c2273617475726174696f6e223a35302c227472616e736974696f6e6475726174696f6e223a313530302c226d6178776f726b74696d65223a302c2262756c625f636f6c6f726d6f6465223a312c2262756c625f7363656e6573223a225b5d222c2262756c625f7363656e65223a22222c2262756c625f7363656e65696478223a3235357d" + ] + }, + "expect": { + "result": { + "red": 128, + "blue": 255, + "green": 128, + "pwr": 1, + "brightness": 75, + "colortemp": 2700, + "hue": 240, + "saturation": 50, + "transitionduration": 1500, + "maxworktime": 0, + "bulb_colormode": 1, + "bulb_scenes": "[]", + "bulb_scene": "", + "bulb_sceneidx": 255 + }, + "sent": [ + [ + 106, + "a5a55a5a6bd4020b3d0000007b22707772223a302c22726564223a312c22626c7565223a332c22677265656e223a322c227472616e736974696f6e6475726174696f6e223a3230307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "send_request", + "args": [ + [ + 1, + 3, + 0, + 0, + 0, + 8 + ] + ], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": { + "__bytes__": "00000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00" + }, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": 21.5, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_external_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": 25.0, + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_full_status", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f00451c" + ] + }, + "expect": { + "result": { + "remote_lock": 1, + "power": 1, + "active": 1, + "temp_manual": 1, + "heating_cooling": 1, + "room_temp": 21.5, + "thermostat_temp": 22.0, + "auto_mode": 1, + "loop_mode": 2, + "sensor": 0, + "osv": 42, + "dif": 2, + "svh": 35, + "svl": 5, + "room_temp_adj": -0.5, + "fre": 0, + "poweron": 1, + "unknown": 32, + "external_temp": 25.0, + "hour": 14, + "min": 30, + "sec": 5, + "dayofweek": 3, + "weekday": [ + { + "start_hour": 6, + "start_minute": 15, + "temp": 20.0 + }, + { + "start_hour": 7, + "start_minute": 15, + "temp": 20.5 + }, + { + "start_hour": 8, + "start_minute": 15, + "temp": 21.0 + }, + { + "start_hour": 9, + "start_minute": 15, + "temp": 21.5 + }, + { + "start_hour": 10, + "start_minute": 15, + "temp": 22.0 + }, + { + "start_hour": 11, + "start_minute": 15, + "temp": 22.5 + } + ], + "weekend": [ + { + "start_hour": 12, + "start_minute": 15, + "temp": 23.0 + }, + { + "start_hour": 13, + "start_minute": 15, + "temp": 23.5 + } + ] + }, + "sent": [ + [ + 106, + "0800010300000016c404" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_mode", + "args": [ + 1, + 2 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "08000106000231003d9a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_mode", + "args": [ + 0, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0800010600021001e40a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_advanced", + "args": [ + 0, + 0, + 42, + 2, + 35, + 5, + -0.5, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "13000110000200050a00002a022305fffb0001e8eb" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "switch_to_auto", + "args": [], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0800010600021100245a" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "switch_to_manual", + "args": [], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060002100025ca" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_temp", + "args": [ + 21.5 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060001002b9815" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_power", + "args": [ + 1, + 0, + 1 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "080001060000008149aa" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_time", + "args": [ + 14, + 30, + 5, + 3 + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0d00011000080002040e1e0503d3b6" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "set_schedule", + "args": [ + [ + { + "start_hour": 6, + "start_minute": 15, + "temp": 20 + }, + { + "start_hour": 7, + "start_minute": 15, + "temp": 21 + }, + { + "start_hour": 8, + "start_minute": 15, + "temp": 22 + }, + { + "start_hour": 9, + "start_minute": 15, + "temp": 23 + }, + { + "start_hour": 10, + "start_minute": 15, + "temp": 24 + }, + { + "start_hour": 11, + "start_minute": 15, + "temp": 25 + } + ], + [ + { + "start_hour": 8, + "start_minute": 0, + "temp": 21 + }, + { + "start_hour": 22, + "start_minute": 30, + "temp": 17.5 + } + ] + ], + "kwargs": {}, + "responses": [ + "0800010600022100305a" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "21000110000a000c18060f070f080f090f0a0f0b0f0800161e282a2c2e30322a2312ca" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hysen", + "devtype": 20141, + "method": "get_temp", + "args": [], + "kwargs": {}, + "responses": [ + "320000000001d12b2c21002a022305fffb000120320e1e0503060f070f080f090f0a0f0b0f0c0f0d0f28292a2b2c2d2e2f0045e3" + ] + }, + "expect": { + "error": "DataValidationError: [Errno -4008] Received data packet check error: Expected a checksum of 58181 and received 7237", + "sent": [ + [ + 106, + "0800010300000008440c" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "0c00bb0006800000020011014768" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_ac_info", + "args": [], + "kwargs": {}, + "responses": [ + "2200bb00070000001800000000010000001a00000000000000000000000000000005a5e0" + ] + }, + "expect": { + "result": { + "power": 1, + "ambient_temp": 26.5 + }, + "sent": [ + [ + 106, + "0c00bb0006800000020021018f90" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "0d00bb000700000003000000013273" + ] + }, + "expect": { + "error": "DataValidationError: [Errno -4007] Received data packet length error: Expected at least 15 bytes and received 3", + "sent": [ + [ + 106, + "0c00bb0006800000020011014768" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 22.5, + 1, + 2, + 0, + 7, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "1900bb00068000000f00010170e48d4000200000200010000577f6" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 24, + 4, + 3, + 2, + 0, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "result": { + "power": true, + "target_temp": 24.0, + "swing_v": 2, + "swing_h": 7, + "mode": 1, + "speed": 2, + "preset": 1, + "sleep": true, + "ifeel": false, + "health": true, + "clean": true, + "display": true, + "mildew": false + }, + "sent": [ + [ + 106, + "1900bb00068000000f00010180040d608080000020001000058a9b" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "hvac", + "devtype": 20010, + "method": "set_state", + "args": [ + true, + 24, + 2, + 1, + 1, + 0, + 0, + false, + false, + true, + false, + false, + false + ], + "kwargs": {}, + "responses": [ + "1900bb00070000000f00000082e4004040240000270010000096af" + ] + }, + "expect": { + "error": "ValueError: turbo is only available in cooling/heating", + "sent": [], + "unused_responses": 1 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb010000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb020000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb030000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "get_percentage", + "args": [], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": 50, + "sent": [ + [ + 106, + "0900bb065d00000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya", + "devtype": 20045, + "method": "set_percentage_and_wait", + "args": [ + 50 + ], + "kwargs": {}, + "responses": [ + "00000000320000000000000000000000", + "00000000320000000000000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0900bb065d00000000fa440000000000" + ], + [ + 106, + "0900bb030000000000fa440000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5abec0020b03000000000100" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5abfc0020b03000000000200" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5ac0c0020b03000000000300" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "get_percentage", + "args": [], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": 40, + "sent": [ + [ + 106, + "0f00a5a55a5ac2c0010b03000000000600" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "dooya2", + "devtype": 20334, + "method": "set_percentage", + "args": [ + 40 + ], + "kwargs": {}, + "responses": [ + "000000000000000000000000000000000028000000000000" + ] + }, + "expect": { + "result": null, + "sent": [ + [ + 106, + "0f00a5a55a5aeec0020b03000000000928" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "get_position", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0a00a5a55a5ab9c0010b0000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "open", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5ad8c1020b030000004a31a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "close", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5af0c1020b030000006132a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "stop", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5a1cc2020b030000004c73a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "wser", + "devtype": 20334, + "method": "set_position", + "args": [ + 30 + ], + "kwargs": {}, + "responses": [ + "00000000000000000000000000001e00" + ] + }, + "expect": { + "result": 30, + "sent": [ + [ + 106, + "0f00a5a55a5aebc1020b030000001e70a0" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_subdevices", + "args": [ + 2 + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b400000007b22746f74616c223a332c226c697374223a5b7b22646964223a226131222c2270777231223a317d2c7b22646964223a226132222c2270777231223a307d5d7d", + "a5a55a5a0000010b400000007b22746f74616c223a332c226c697374223a5b7b22646964223a226132222c2270777231223a307d2c7b22646964223a226133222c2270777231223a317d5d7d" + ] + }, + "expect": { + "result": [ + { + "did": "a1", + "pwr1": 1 + }, + { + "did": "a2", + "pwr1": 0 + }, + { + "did": "a3", + "pwr1": 1 + } + ], + "sent": [ + [ + 106, + "a5a55a5a9ec70e0b150000007b22636f756e74223a322c22696e646578223a307d" + ], + [ + 106, + "a5a55a5aa0c70e0b150000007b22636f756e74223a322c22696e646578223a327d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_state", + "args": [], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b0a0000007b2270777231223a317d" + ] + }, + "expect": { + "result": { + "pwr1": 1 + }, + "sent": [ + [ + 106, + "a5a55a5ab3c1010b020000007b7d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "get_state", + "args": [ + "a1" + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b0a0000007b2270777231223a317d" + ] + }, + "expect": { + "result": { + "pwr1": 1 + }, + "sent": [ + [ + 106, + "a5a55a5a42c4010b0c0000007b22646964223a226131227d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "s3", + "devtype": 42396, + "method": "set_state", + "args": [ + "a1", + true, + null, + false + ], + "kwargs": {}, + "responses": [ + "a5a55a5a0000010b130000007b2270777231223a312c2270777233223a307d" + ] + }, + "expect": { + "result": { + "pwr1": 1, + "pwr3": 0 + }, + "sent": [ + [ + 106, + "a5a55a5a20c9020b1e0000007b22646964223a226131222c2270777231223a312c2270777233223a307d" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "S1C", + "devtype": 10018, + "method": "get_sensors_status", + "args": [], + "kwargs": {}, + "responses": [ + "0000000002000101003146726f6e7420646f6f720000000000000000000000000102030400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002002148616c6c0000000000000000000000000000000000000a0b0c0d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003009100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ] + }, + "expect": { + "result": { + "count": 2, + "sensors": [ + { + "status": 1, + "name": "Front door", + "type": "Door Sensor", + "order": 1, + "serial": "01020304" + }, + { + "status": 0, + "name": "Hall", + "type": "Motion Sensor", + "order": 2, + "serial": "0a0b0c0d" + } + ] + }, + "sent": [ + [ + 106, + "06000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rm4mini", + "devtype": 20954, + "method": "check_data", + "args": [], + "kwargs": {}, + "responses": [] + }, + "expect": { + "error": "AssertionError: method sent more packets than canned responses (1 sent)", + "sent": [ + [ + 106, + "040004000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "rmmini", + "devtype": 10039, + "method": "enter_learning", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "error_code": 65531 + }, + "expect": { + "error": "StorageError: [Errno -5] The device storage is full", + "sent": [ + [ + 106, + "03000000" + ] + ], + "unused_responses": 0 + } + }, + { + "case": { + "cls": "sp2", + "devtype": 10001, + "method": "check_power", + "args": [], + "kwargs": {}, + "responses": [ + "00000000000000000000000000000000" + ], + "error_code": 65529 + }, + "expect": { + "error": "AuthorizationError: [Errno -7] Control key is expired", + "sent": [ + [ + 106, + "01000000000000000000000000000000" + ] + ], + "unused_responses": 0 + } + } +] diff --git a/tests/oracle/harness.py b/tests/oracle/harness.py new file mode 100644 index 00000000..9fb8d2d0 --- /dev/null +++ b/tests/oracle/harness.py @@ -0,0 +1,158 @@ +"""Harness that records what device methods send and what they decode. + +Every public method on a device class ends up calling ``Device.send_packet`` +with a packet type and a plaintext payload, and then decoding whatever the +device answers. The transport (framing, encryption, retries) lives in +``send_packet`` itself and is tested separately. This harness replaces +``send_packet`` on one device instance so that: + +- each call is recorded as ``(packet_type, payload)`` before encryption, and +- each call is answered with a well-formed response frame carrying the next + canned payload, encrypted with the device's current session key so that the + method's own ``decrypt`` sees exactly those bytes. + +The recorded sequence and the method's return value are the "oracle": a +later reimplementation of the same method (for example, an asynchronous one) +must produce the same sequence and the same result from the same canned +responses. Results are normalized to plain JSON so they can be stored. + +The runner accepts awaitables so the same cases can drive an asynchronous +``send_packet`` later without changing the cases. +""" + +from __future__ import annotations + +import asyncio +import enum +import inspect +from dataclasses import dataclass, field +from typing import Any + +import broadlink +from broadlink.device import Device + +# A fixed identity so recorded bytes never depend on random state. +MAC = bytes.fromhex("a043b05510f7") +HOST = ("192.0.2.10", 80) + + +def pad16(payload: bytes) -> bytes: + """Pad to the AES block size, as the device does before encrypting.""" + return bytes(payload) + bytes((16 - len(payload)) % 16) + + +def make_response(device: Device, payload: bytes, error: int = 0) -> bytes: + """Build a response frame the way a device would answer ``send_packet``. + + Only the parts the device classes read are meaningful: the error code + at 0x22:0x24 and the encrypted payload from 0x38. The frame checksum is + filled in so the frame would also pass ``send_packet``'s own check. + """ + frame = bytearray(0x38) + frame[0x00:0x08] = bytes.fromhex("5aa5aa555aa5aa55") + frame[0x22:0x24] = (error & 0xFFFF).to_bytes(2, "little") + frame[0x24:0x26] = device.devtype.to_bytes(2, "little") + frame[0x2A:0x30] = device.mac[::-1] + frame.extend(device.encrypt(pad16(payload))) + checksum = sum(frame, 0xBEAF) & 0xFFFF + frame[0x20:0x22] = checksum.to_bytes(2, "little") + return bytes(frame) + + +@dataclass +class Recorder: + """Replacement ``send_packet`` that records requests and serves responses.""" + + device: Device + responses: list[bytes] + error: int = 0 + sent: list[tuple[int, bytes]] = field(default_factory=list) + + def __call__(self, packet_type: int, payload: bytes) -> bytes: + self.sent.append((packet_type, bytes(payload))) + if not self.responses: + raise AssertionError( + f"method sent more packets than canned responses " + f"({len(self.sent)} sent)" + ) + return make_response(self.device, self.responses.pop(0), self.error) + + async def async_call(self, packet_type: int, payload: bytes) -> bytes: + return self(packet_type, payload) + + +def normalize(value: Any) -> Any: + """Turn a method result into plain JSON-compatible data.""" + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, (bytes, bytearray)): + return {"__bytes__": bytes(value).hex()} + if isinstance(value, dict): + return {str(k): normalize(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [normalize(v) for v in value] + if isinstance(value, float): + return round(value, 6) + return value + + +def build_device(cls_name: str, devtype: int) -> Device: + """Instantiate a device class by name with the fixed test identity.""" + cls = getattr(broadlink, cls_name) + return cls(HOST, MAC, devtype, name="Bench", model="Test", manufacturer="Test") + + +def run_case(case: dict) -> dict: + """Execute one case and return the recorded outcome. + + ``case`` has: ``cls``, ``devtype``, ``method``, ``args``, ``kwargs``, + ``responses`` (list of hex payloads), and optionally ``setup`` (attribute + values applied before the call) and ``attrs`` (attribute names to record + after the call). + """ + device = build_device(case["cls"], case["devtype"]) + for name, value in case.get("setup", {}).items(): + setattr(device, name, value) + + responses = [bytes.fromhex(r) for r in case.get("responses", [])] + recorder = Recorder(device, responses, case.get("error_code", 0)) + target = getattr(device, "send_packet") + if inspect.iscoroutinefunction(target): + device.send_packet = recorder.async_call # type: ignore[method-assign] + else: + device.send_packet = recorder # type: ignore[method-assign] + + method = getattr(device, case["method"]) + args = [decode_arg(a) for a in case.get("args", [])] + kwargs = {k: decode_arg(v) for k, v in case.get("kwargs", {}).items()} + + outcome: dict[str, Any] = {} + try: + result = method(*args, **kwargs) + if inspect.isawaitable(result): + result = asyncio.run(_await(result)) + outcome["result"] = normalize(result) + except Exception as err: # noqa: BLE001 - the error type IS the oracle + outcome["error"] = f"{type(err).__name__}: {err}" + + outcome["sent"] = [[ptype, payload.hex()] for ptype, payload in recorder.sent] + outcome["unused_responses"] = len(recorder.responses) + attrs = case.get("attrs", []) + if attrs: + outcome["attrs"] = {a: normalize(getattr(device, a)) for a in attrs} + return outcome + + +async def _await(awaitable): + return await awaitable + + +def decode_arg(value: Any) -> Any: + """Cases store bytes arguments as {"__bytes__": hex}.""" + if isinstance(value, dict) and set(value) == {"__bytes__"}: + return bytes.fromhex(value["__bytes__"]) + if isinstance(value, list): + return [decode_arg(v) for v in value] + if isinstance(value, dict): + return {k: decode_arg(v) for k, v in value.items()} + return value diff --git a/tests/oracle/record.py b/tests/oracle/record.py new file mode 100644 index 00000000..82e8ccc6 --- /dev/null +++ b/tests/oracle/record.py @@ -0,0 +1,38 @@ +"""Record the oracle fixtures from the current library. + +Run from the repository root: + + python -m tests.oracle.record + +This overwrites ``tests/oracle/fixtures.json``. Only run it when the recorded +behavior is meant to change (for example, a deliberate protocol fix), and +review the diff of the fixture file in the same pull request. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from . import cases as case_module +from .harness import run_case + +FIXTURES = Path(__file__).with_name("fixtures.json") + + +def build() -> list[dict]: + entries = [] + for case in case_module.all_cases() + case_module.error_cases(): + outcome = run_case(case) + entries.append({"case": case, "expect": outcome}) + return entries + + +def main() -> None: + entries = build() + FIXTURES.write_text(json.dumps(entries, indent=1) + "\n") + print(f"recorded {len(entries)} cases to {FIXTURES}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 00000000..04629daf --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,93 @@ +"""Pure helpers: pulse packing, CRC16 and the protocol datetime.""" + +from __future__ import annotations + +import datetime as dt + +import pytest + +from broadlink.helpers import CRC16 +from broadlink.protocol import Datetime +from broadlink.remote import data_to_pulses, pulses_to_data + +# NOTE: these pin the 0.19.0 behavior of the pulse helpers, including the +# 32.84 tick that upstream issue #839 identifies as wrong. They are expected +# to change, deliberately and in the same pull request, when the tick fix +# lands; until then they document what shipped. + + +def test_pulses_to_data_header_and_short_pulses(): + data = pulses_to_data([328, 656], tick=32.84) + assert data[0] == 0x26 + assert data[1] == 0x00 + assert int.from_bytes(data[2:4], "little") == 2 + assert data[4:] == bytes([9, 19]) # floor(328/32.84)=9, floor(656/32.84)=19 + + +def test_pulses_to_data_long_pulse_uses_three_byte_form(): + data = pulses_to_data([10000], tick=32.84) + ticks = int(10000 // 32.84) # 304 + assert data[4:] == bytes([0, ticks >> 8, ticks & 0xFF]) + assert int.from_bytes(data[2:4], "little") == 3 + + +def test_data_to_pulses_round_trip_at_same_tick(): + pulses = [9000, 4500, 560, 560, 560, 1690, 40000] + data = pulses_to_data(pulses) + back = data_to_pulses(data) + # Both directions use the same tick, so the round trip lands within a tick. + for a, b in zip(pulses, back, strict=True): + assert abs(a - b) <= 33 + + +def test_data_to_pulses_honors_declared_length(): + data = pulses_to_data([328, 656]) + b"\x0d\x05" # trailing terminator bytes + assert len(data_to_pulses(data)) == 2 + + +def test_data_to_pulses_rejects_truncated_long_form(): + with pytest.raises(ValueError): + data_to_pulses(bytes([0x26, 0x00, 0x02, 0x00, 0x00, 0x01])) + + +def test_crc16_known_vector(): + # CRC-16/MODBUS of "123456789" is 0x4B37. + assert CRC16.calculate(b"123456789") == 0x4B37 + assert CRC16.calculate(b"") == 0xFFFF + + +def test_crc16_table_is_cached(): + CRC16._cache.pop(0xA001, None) + t1 = CRC16.get_table(0xA001) + t2 = CRC16.get_table(0xA001) + assert t1 is t2 + assert len(t1) == 256 + + +def test_datetime_pack_layout(): + tz = dt.timezone(dt.timedelta(hours=-7)) + when = dt.datetime(2026, 9, 4, 14, 30, 0, tzinfo=tz) + data = Datetime.pack(when) + assert len(data) == 12 + assert int.from_bytes(data[0:4], "little", signed=True) == -7 + assert int.from_bytes(data[4:6], "little") == 2026 + assert data[6] == 30 + assert data[7] == 14 + assert data[8] == 26 + assert data[9] == 5 # Friday + assert data[10] == 4 + assert data[11] == 9 + + +def test_datetime_round_trip_and_validation(): + tz = dt.timezone(dt.timedelta(hours=2)) + when = dt.datetime(2026, 1, 15, 8, 5, 0, tzinfo=tz) + data = bytearray(Datetime.pack(when)) + assert Datetime.unpack(bytes(data)) == when + data[9] = 1 # wrong weekday + with pytest.raises(ValueError): + Datetime.unpack(bytes(data)) + + +def test_datetime_now_has_tzinfo(): + assert Datetime.now().tzinfo is not None diff --git a/tests/test_oracle.py b/tests/test_oracle.py new file mode 100644 index 00000000..7ae7d26d --- /dev/null +++ b/tests/test_oracle.py @@ -0,0 +1,54 @@ +"""Replay the recorded oracle: every device method must send the same bytes +and decode the same result it did when the fixtures were recorded.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tests.oracle.harness import run_case + +FIXTURES = Path(__file__).parent / "oracle" / "fixtures.json" +ENTRIES = json.loads(FIXTURES.read_text()) + + +def _ident(entry: dict) -> str: + c = entry["case"] + return f"{c['cls']}.{c['method']}" + + +@pytest.mark.parametrize("entry", ENTRIES, ids=[_ident(e) for e in ENTRIES]) +def test_oracle(entry: dict) -> None: + outcome = run_case(entry["case"]) + assert outcome == entry["expect"] + + +def test_every_public_method_is_covered() -> None: + """Fail when a device class grows a public method the oracle does not know.""" + import inspect + + import broadlink + from broadlink.device import Device + + covered = {(e["case"]["cls"], e["case"]["method"]) for e in ENTRIES} + # Methods on Device itself that need a live socket are covered in + # test_transport.py, not here. + transport_level = {"auth", "hello", "ping", "send_packet", "encrypt", "decrypt", + "update_aes"} + missing = [] + for name, cls in inspect.getmembers(broadlink, inspect.isclass): + if not issubclass(cls, Device): + continue + for meth, _ in inspect.getmembers(cls, inspect.isfunction): + if meth.startswith("_") or meth in transport_level: + continue + # Inherited methods are covered on the class that defines them + # or on a subclass case; require at least one case per class/method + # pair where the method is defined on that class. + if meth not in cls.__dict__: + continue + if (name, meth) not in covered: + missing.append(f"{name}.{meth}") + assert not missing, f"public methods without an oracle case: {missing}" diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 00000000..7fd8e784 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,383 @@ +"""Transport layer: framing, encryption, checksums, discovery and auth. + +These tests replace the UDP socket with a fake so the exact bytes that leave +``send_packet`` and ``scan`` can be checked, and so response validation can +be exercised with corrupted frames. +""" + +from __future__ import annotations + +import socket + +import pytest + +import broadlink +from broadlink import device as device_module +from broadlink import exceptions as e +from broadlink.device import Device +from tests.oracle.harness import HOST, MAC, make_response + +INIT_KEY = bytes.fromhex("097628343fe99e23765c1513accf8b02") +INIT_VECT = bytes.fromhex("562e17996d093d28ddb3ba695a2e6f58") + + +class FakeSocket: + """A UDP socket stand-in: records sendto, replays canned recvfrom.""" + + instances: list["FakeSocket"] = [] + + def __init__(self, *args, **kwargs): + self.sent: list[tuple[bytes, tuple[str, int]]] = [] + self.inbox: list[tuple[bytes, tuple[str, int]]] = list(FakeSocket.queue) + self.timeout = None + self.closed = False + self.bound = None + FakeSocket.instances.append(self) + + queue: list[tuple[bytes, tuple[str, int]]] = [] + + def setsockopt(self, *args): + pass + + def settimeout(self, value): + self.timeout = value + + def bind(self, addr): + self.bound = addr + + def getsockname(self): + return self.bound or ("0.0.0.0", 0) + + def sendto(self, data, addr): + self.sent.append((bytes(data), addr)) + + def recvfrom(self, size): + if not self.inbox: + raise socket.timeout() + return self.inbox.pop(0) + + def close(self): + self.closed = True + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +@pytest.fixture +def fake_socket(monkeypatch): + FakeSocket.instances = [] + FakeSocket.queue = [] + monkeypatch.setattr(device_module.socket, "socket", FakeSocket) + monkeypatch.setattr(broadlink.socket, "socket", FakeSocket) + # Keep the retry loop from waiting on real time. + monkeypatch.setattr(device_module, "DEFAULT_RETRY_INTVL", 0.001) + return FakeSocket + + +def fixed_device(cls=Device, devtype=0x2737) -> Device: + dev = cls(HOST, MAC, devtype, name="Bench") + dev.count = 0x8000 + return dev + + +# ------------------------------------------------------------------ send_packet + + +def test_send_packet_wire_bytes(fake_socket): + dev = fixed_device() + dev.id = 0x00000001 + payload = bytes([0x01]) + bytes(15) + fake_socket.queue = [(make_response(dev, bytes(16)), HOST)] + + resp = dev.send_packet(0x6A, payload) + + sock = fake_socket.instances[-1] + assert len(sock.sent) == 1 + frame, addr = sock.sent[0] + assert addr == HOST + assert frame[0x00:0x08] == bytes.fromhex("5aa5aa555aa5aa55") + assert frame[0x24:0x26] == (0x2737).to_bytes(2, "little") + assert frame[0x26:0x28] == (0x6A).to_bytes(2, "little") + assert frame[0x28:0x2A] == (0x8001).to_bytes(2, "little") # count advanced + assert frame[0x2A:0x30] == MAC[::-1] + assert frame[0x30:0x34] == (1).to_bytes(4, "little") + assert frame[0x34:0x36] == (sum(payload, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + # Encrypted payload: one AES block, decrypts back to the plaintext. + assert len(frame) == 0x38 + 16 + assert dev.decrypt(frame[0x38:]) == payload + # Frame checksum is computed over the frame with the checksum field zeroed. + body = bytearray(frame) + body[0x20:0x22] = b"\x00\x00" + assert frame[0x20:0x22] == (sum(body, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + assert resp[0x22:0x24] == b"\x00\x00" + assert dev.count == 0x8001 + + +def test_send_packet_pads_payload_to_block(fake_socket): + dev = fixed_device() + fake_socket.queue = [(make_response(dev, b""), HOST)] + dev.send_packet(0x6A, bytes(20)) + frame = fake_socket.instances[-1].sent[0][0] + assert len(frame) == 0x38 + 32 + assert dev.decrypt(frame[0x38:]) == bytes(32) + + +def test_send_packet_counter_wraps_with_high_bit(fake_socket): + dev = fixed_device() + dev.count = 0xFFFF + fake_socket.queue = [(make_response(dev, b""), HOST)] + dev.send_packet(0x6A, b"") + assert dev.count == 0x8000 + + +def test_send_packet_retries_then_times_out(fake_socket, monkeypatch): + dev = fixed_device() + dev.timeout = 0.01 + fake_socket.queue = [] # never answers + with pytest.raises(e.NetworkTimeoutError) as err: + dev.send_packet(0x6A, b"") + assert err.value.errno == -4000 + assert len(fake_socket.instances[-1].sent) >= 1 + + +def test_send_packet_rejects_short_response(fake_socket): + dev = fixed_device() + fake_socket.queue = [(bytes(0x10), HOST)] + with pytest.raises(e.DataValidationError) as err: + dev.send_packet(0x6A, b"") + assert err.value.errno == -4007 + + +def test_send_packet_rejects_bad_checksum(fake_socket): + dev = fixed_device() + frame = bytearray(make_response(dev, b"")) + frame[0x20] ^= 0xFF + fake_socket.queue = [(bytes(frame), HOST)] + with pytest.raises(e.DataValidationError) as err: + dev.send_packet(0x6A, b"") + assert err.value.errno == -4008 + + +# ------------------------------------------------------------------------- auth + + +def test_auth_uses_initial_key_and_installs_session_key(fake_socket): + dev = fixed_device() + dev.id = 99 # stale session; auth must reset it before sending + dev.update_aes(bytes(range(16))) # stale key + + session_id = 0x0000BEEF + session_key = bytes.fromhex("00112233445566778899aabbccddeeff") + # The auth response payload: id at 0:4, key at 4:20, encrypted with the + # INITIAL key, which is what the device expects auth to be decrypted with. + fresh = fixed_device() + reply = make_response(fresh, session_id.to_bytes(4, "little") + session_key) + fake_socket.queue = [(reply, HOST)] + + assert dev.auth() is True + + frame = fake_socket.instances[-1].sent[0][0] + assert frame[0x26:0x28] == (0x65).to_bytes(2, "little") + assert frame[0x30:0x34] == bytes(4) # id reset to 0 for the handshake + plaintext = fresh.decrypt(frame[0x38:]) + assert plaintext[0x04:0x14] == bytes([0x31]) * 16 + assert plaintext[0x1E] == 0x01 + assert plaintext[0x2D] == 0x01 + assert plaintext[0x30:0x36] == b"Test 1" + assert len(plaintext) == 0x50 + + assert dev.id == session_id + # The new key is in use: encrypting with it matches an independent cipher. + probe = fixed_device() + probe.update_aes(session_key) + assert dev.encrypt(bytes(16)) == probe.encrypt(bytes(16)) + + +def test_auth_surfaces_device_error(fake_socket): + dev = fixed_device() + fake_socket.queue = [(make_response(dev, bytes(20), error=0xFFF9), HOST)] + with pytest.raises(e.AuthorizationError): + dev.auth() + + +# ------------------------------------------------------------------- discovery + + +def hello_response(devtype: int, mac: bytes, name: str, locked: bool) -> bytes: + frame = bytearray(0x80) + frame[0x34:0x36] = devtype.to_bytes(2, "little") + frame[0x3A:0x40] = mac[::-1] + frame[0x40 : 0x40 + len(name)] = name.encode() + frame[0x7F] = int(locked) + return bytes(frame) + + +def test_scan_builds_hello_packet_and_parses_replies(fake_socket): + fake_socket.queue = [ + (hello_response(0x6026, MAC, "Bedroom RM", False), ("192.0.2.10", 80)), + (hello_response(0x6026, MAC, "Bedroom RM", False), ("192.0.2.10", 80)), # dup + (hello_response(0x2711, bytes.fromhex("34ea34000001"), "Plug", True), + ("192.0.2.11", 80)), + ] + found = list(device_module.scan(timeout=0.01, local_ip_address="192.0.2.2")) + + assert found == [ + (0x6026, ("192.0.2.10", 80), MAC, "Bedroom RM", False), + (0x2711, ("192.0.2.11", 80), bytes.fromhex("34ea34000001"), "Plug", True), + ] + sock = fake_socket.instances[-1] + assert sock.bound == ("192.0.2.2", 0) + packet, addr = sock.sent[0] + assert addr == ("255.255.255.255", 80) + assert len(packet) == 0x30 + assert packet[0x26] == 6 + assert packet[0x18:0x1C] == socket.inet_aton("192.0.2.2")[::-1] + body = bytearray(packet) + body[0x20:0x22] = b"\x00\x00" + assert packet[0x20:0x22] == (sum(body, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + assert sock.closed + + +def test_discover_and_hello_build_devices(fake_socket): + fake_socket.queue = [ + (hello_response(0x6026, MAC, "Bedroom RM", False), ("192.0.2.10", 80)), + ] + devices = broadlink.discover(timeout=0.01) + assert len(devices) == 1 + dev = devices[0] + assert isinstance(dev, broadlink.rm4pro) + assert dev.host == ("192.0.2.10", 80) + assert dev.mac == MAC + assert dev.name == "Bedroom RM" + assert dev.model == "RM4 pro" + assert dev.manufacturer == "Broadlink" + + fake_socket.queue = [ + (hello_response(0x6026, MAC, "Bedroom RM", True), ("192.0.2.10", 80)), + ] + dev = broadlink.hello("192.0.2.10", timeout=0.01) + assert dev.is_locked is True + assert fake_socket.instances[-1].sent[0][1] == ("192.0.2.10", 80) + + +def test_hello_times_out(fake_socket): + fake_socket.queue = [] + with pytest.raises(e.NetworkTimeoutError): + broadlink.hello("192.0.2.10", timeout=0.01) + + +def test_device_hello_validates_identity(fake_socket): + dev = fixed_device(broadlink.rm4pro, 0x6026) + fake_socket.queue = [(hello_response(0x6026, MAC, "Renamed", True), HOST)] + assert dev.hello() is True + assert dev.name == "Renamed" + assert dev.is_locked is True + + fake_socket.queue = [ + (hello_response(0x6026, bytes.fromhex("000000000001"), "Other", False), HOST) + ] + with pytest.raises(e.DataValidationError): + dev.hello() + + fake_socket.queue = [(hello_response(0x2711, MAC, "Other", False), HOST)] + with pytest.raises(e.DataValidationError): + dev.hello() + + +def test_ping_packet(fake_socket): + dev = fixed_device() + dev.ping() + packet, addr = fake_socket.instances[-1].sent[0] + assert addr == HOST + assert len(packet) == 0x30 + assert packet[0x26] == 1 + + +# ------------------------------------------------------------------ gendevice + + +@pytest.mark.parametrize( + ("devtype", "cls", "model"), + [ + (0x2737, broadlink.rmmini, "RM mini 3"), + (0x272A, broadlink.rmpro, "RM pro"), + (0x5F36, broadlink.rmminib, "RM mini 3"), + (0x51DA, broadlink.rm4mini, "RM4 mini"), + (0x6026, broadlink.rm4pro, "RM4 pro"), + (0x2711, broadlink.sp2s, "SP2"), + (0x2720, broadlink.sp2, "SP mini"), + (0x2714, broadlink.a1, "A1"), + (0x4EAD, broadlink.hysen, "HY02/HY03"), + (0x60C7, broadlink.lb1, "LB1"), + (0x4EB5, broadlink.mp1, "MP1-1K4S"), + ], +) +def test_gendevice_known_ids(devtype, cls, model): + dev = broadlink.gendevice(devtype, HOST, MAC) + assert type(dev) is cls + assert dev.model == model + assert dev.type == cls.TYPE + + +def test_gendevice_unknown_id_is_generic_device(): + dev = broadlink.gendevice(0xFFFF, HOST, "a043b05510f7") + assert type(dev) is Device + assert dev.type == "Unknown" + assert dev.mac == MAC + + +def test_product_table_has_no_duplicate_ids(): + seen = {} + for cls, products in broadlink.SUPPORTED_TYPES.items(): + for pid in products: + assert pid not in seen, f"{pid:#06x} in both {seen[pid]} and {cls}" + seen[pid] = cls.__name__ + + +# ----------------------------------------------------------------------- setup + + +def test_setup_packet(fake_socket): + broadlink.setup("MyWifi", "hunter2", 3, ip_address="192.0.2.255") + packet, addr = fake_socket.instances[-1].sent[0] + assert addr == ("192.0.2.255", 80) + assert len(packet) == 0x88 + assert packet[0x26] == 0x14 + assert packet[68:74] == b"MyWifi" + assert packet[100:107] == b"hunter2" + assert packet[0x84] == 6 + assert packet[0x85] == 7 + assert packet[0x86] == 3 + body = bytearray(packet) + body[0x20:0x22] = b"\x00\x00" + assert packet[0x20:0x22] == (sum(body, 0xBEAF) & 0xFFFF).to_bytes(2, "little") + + +# ------------------------------------------------------------------ exceptions + + +@pytest.mark.parametrize( + ("code", "exc"), + [ + (0xFFFF, e.AuthenticationError), + (0xFFF9, e.AuthorizationError), + (0xFFFB, e.StorageError), + (0xFFFE, e.ConnectionClosedError), + ], +) +def test_check_error_maps_codes(code, exc): + with pytest.raises(exc): + e.check_error(code.to_bytes(2, "little")) + + +def test_check_error_passes_zero(): + e.check_error(b"\x00\x00") + + +def test_check_error_unknown_code(): + with pytest.raises(e.UnknownError) as err: + e.check_error((0x1234).to_bytes(2, "little")) + assert err.value.errno == 0x1234