From 253aaecd1541811a9eb7601d7614353821787747 Mon Sep 17 00:00:00 2001 From: Aaron Gotwalt Date: Thu, 27 Aug 2026 11:17:03 -0700 Subject: [PATCH] Publish libkp on PyPI, and move the Home Assistant integration out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration vendored the library through a symlink the bundler had to dereference. Publishing libkp instead makes it an ordinary requirement: the integration names a release in its manifest and Home Assistant installs it, so there is no copy of the library to keep in sync and nothing to unpick at build time. It moves to gotwalt/kemper-homeassistant, which is what HACS can take — HACS resolves a component by walking the repository root for custom_components/, and never saw it nested under python/examples. The fake Profiler moves with it, the other way: tests/fake_device.py becomes libkp.testing, shipped in the wheel. It is what let the integration's tests hold a real session over a loopback socket with nothing below the config entry mocked, and it can only go on doing that from inside the package. Releases are tag-driven and publish over Trusted Publishing, so no API token lives in this repository: push python-v and the workflow checks the tag against pyproject.toml and __init__.py before it builds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BQn7c64v3STBbMhyRq13eG --- .github/workflows/ci.yml | 18 -- .github/workflows/publish-python.yml | 56 +++++ README.md | 15 +- assets/kemper.png | Bin 0 -> 155353 bytes python/README.md | 30 ++- python/examples/homeassistant/README.md | 119 ---------- python/examples/homeassistant/build.py | 96 -------- .../custom_components/kemper/__init__.py | 130 ----------- .../custom_components/kemper/activity.py | 199 ---------------- .../custom_components/kemper/binary_sensor.py | 43 ---- .../custom_components/kemper/config_flow.py | 218 ------------------ .../custom_components/kemper/const.py | 31 --- .../custom_components/kemper/coordinator.py | 191 --------------- .../custom_components/kemper/diagnostics.py | 60 ----- .../custom_components/kemper/discovery.py | 82 ------- .../custom_components/kemper/entity.py | 30 --- .../custom_components/kemper/icons.json | 27 --- .../custom_components/kemper/libkp | 1 - .../custom_components/kemper/manifest.json | 13 -- .../custom_components/kemper/sensor.py | 102 -------- .../custom_components/kemper/strings.json | 69 ------ .../kemper/translations/en.json | 69 ------ python/examples/homeassistant/pyproject.toml | 33 --- .../examples/homeassistant/tests/conftest.py | 129 ----------- .../homeassistant/tests/test_binary_sensor.py | 161 ------------- .../homeassistant/tests/test_build.py | 75 ------ .../homeassistant/tests/test_config_flow.py | 201 ---------------- .../homeassistant/tests/test_diagnostics.py | 33 --- .../examples/homeassistant/tests/test_init.py | 204 ---------------- .../homeassistant/tests/test_sensor.py | 62 ----- python/src/libkp/__init__.py | 3 + .../fake_device.py => src/libkp/testing.py} | 13 +- python/tests/test_cbor.py | 3 +- python/tests/test_meters_example.py | 2 +- python/tests/test_model.py | 2 +- python/tests/test_session.py | 2 +- 36 files changed, 112 insertions(+), 2410 deletions(-) create mode 100644 .github/workflows/publish-python.yml create mode 100644 assets/kemper.png delete mode 100644 python/examples/homeassistant/README.md delete mode 100644 python/examples/homeassistant/build.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/__init__.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/activity.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/binary_sensor.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/config_flow.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/const.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/coordinator.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/diagnostics.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/discovery.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/entity.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/icons.json delete mode 120000 python/examples/homeassistant/custom_components/kemper/libkp delete mode 100644 python/examples/homeassistant/custom_components/kemper/manifest.json delete mode 100644 python/examples/homeassistant/custom_components/kemper/sensor.py delete mode 100644 python/examples/homeassistant/custom_components/kemper/strings.json delete mode 100644 python/examples/homeassistant/custom_components/kemper/translations/en.json delete mode 100644 python/examples/homeassistant/pyproject.toml delete mode 100644 python/examples/homeassistant/tests/conftest.py delete mode 100644 python/examples/homeassistant/tests/test_binary_sensor.py delete mode 100644 python/examples/homeassistant/tests/test_build.py delete mode 100644 python/examples/homeassistant/tests/test_config_flow.py delete mode 100644 python/examples/homeassistant/tests/test_diagnostics.py delete mode 100644 python/examples/homeassistant/tests/test_init.py delete mode 100644 python/examples/homeassistant/tests/test_sensor.py rename python/{tests/fake_device.py => src/libkp/testing.py} (97%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21404b2..6c65589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,24 +70,6 @@ jobs: - run: ruff format --check . - run: python -m pytest -q - homeassistant: - name: Home Assistant integration lint + test - runs-on: ubuntu-latest - defaults: - run: - working-directory: python/examples/homeassistant - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.14" - - uses: astral-sh/setup-uv@v5 - - run: uv sync - - run: uv run ruff check . - - run: uv run ruff format --check . - - run: uv run pytest -q - - run: uv run python build.py - swift: name: Swift lint + test runs-on: macos-15 diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml new file mode 100644 index 0000000..28efcd3 --- /dev/null +++ b/.github/workflows/publish-python.yml @@ -0,0 +1,56 @@ +name: Publish libkp to PyPI + +# Tag-driven: `git tag python-v0.1.0 && git push --tags` builds python/ and +# publishes it. Authentication is PyPI Trusted Publishing over OIDC — the +# `id-token: write` permission below is the whole credential, so there is no +# API token in this repository. +on: + push: + tags: ["python-v*"] + workflow_dispatch: + +permissions: {} + +jobs: + build: + name: Build the distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: The tag and the package agree on the version + if: startsWith(github.ref, 'refs/tags/') + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME#python-v}" + pkg=$(grep -m1 '^version = ' python/pyproject.toml | cut -d'"' -f2) + init=$(grep -m1 '^__version__ = ' python/src/libkp/__init__.py | cut -d'"' -f2) + echo "tag=$tag pyproject=$pkg __version__=$init" + test "$tag" = "$pkg" + test "$tag" = "$init" + + - run: pip install build + - run: python -m build python/ + - uses: actions/upload-artifact@v4 + with: + name: dist + path: python/dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/libkp + 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/README.md b/README.md index 51f5a19..7c17f87 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ with my hands. | Python | `libkp` | Python 3.11+, standard library only | [`python/`](python/) | | Swift | `LibKP` | Swift 6, macOS 13+, `Network` framework | [`swift/`](swift/) | -None of the three is on a package registry yet; depend on the directory as a -path or git dependency. The shape of the API is the same everywhere — find a -device, connect a model, subscribe to its state, send it things: +Python is on PyPI — `pip install libkp`. Rust and Swift are not on a registry +yet; depend on the directory as a path or git dependency. The shape of the API +is the same everywhere — find a device, connect a model, subscribe to its +state, send it things: ```rust use libkp::model::DeviceModel; @@ -69,6 +70,14 @@ cd swift && swift run meters # or `swift run MetersApp` fo Discovery needs UDP port 5727 to itself, so quit Kemper's Rig Manager (or pass `--ip`) before running it. +### Built on libkp + +- [**kemper-homeassistant**](https://github.com/gotwalt/kemper-homeassistant) — + a Home Assistant integration: the rig, the amp, the cabinet, and whether + anyone is playing, held on one session that never polls the device. It + depends on `libkp` from PyPI, and tests against + [`libkp.testing.FakeDevice`](python/README.md#testing-against-a-fake-profiler). + ## Status Tested against a **Profiler Player on firmware 14.2.1**. Other Profiler models diff --git a/assets/kemper.png b/assets/kemper.png new file mode 100644 index 0000000000000000000000000000000000000000..1509ffa8a2399f9b651db7abccf6427ab4715428 GIT binary patch literal 155353 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%0DW_bk-5yH66z#dwNe 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 z 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 literal 0 HcmV?d00001 diff --git a/python/README.md b/python/README.md index a25d2a8..c7456f6 100644 --- a/python/README.md +++ b/python/README.md @@ -14,7 +14,8 @@ the CC control vocabulary, and an observable async device model. ## Install ```sh -pip install -e '.[dev]' # from python/ +pip install libkp # from PyPI +pip install -e '.[dev]' # or from python/, to work on it ``` Or run straight from the source tree: @@ -153,6 +154,7 @@ with DiscoveryPort.acquire() as port: # raises PortUnavailableError if taken | `libkp.state` | The state tree and the pure `DeviceState.apply_update` fold. | | `libkp.model` | `DeviceModel`, the async store over the stream and the control link. | | `libkp.errors` | The exception family, all deriving from `LibKPError`. | +| `libkp.testing` | `FakeDevice`, an in-process Profiler to test against. | | `libkp._generated` | **Generated, data only** — constants and lookup tables. Do not edit. | `_generated.py` is emitted from [`../spec`](../spec) by @@ -371,6 +373,30 @@ for message in unframer.push(raw_stream_bytes): ... ``` +## Testing against a fake Profiler + +`libkp.testing.FakeDevice` is a Profiler stand-in that speaks the real +transport in-process: the greeting, the protocol-selection handshake, the +preamble, then MIDI3 framing or the CBOR dump. Anything built on libkp can hold +a session against it in its own suite, with nothing below the socket mocked and +no device on the desk. + +```python +from libkp import DeviceModel +from libkp.testing import FakeDevice, answer_requests + +fake = await FakeDevice(responder=answer_requests).start() +model = await DeviceModel.connect("127.0.0.1", port=fake.port) +... +await model.close() +await fake.stop() +``` + +It can also hang up mid-session, hold back the greeting, or refuse connections +for a while — the states a reconnect has to survive. libkp's own async tests +drive it; so does the [Home Assistant +integration](https://github.com/gotwalt/kemper-homeassistant). + ## Tests ```sh @@ -388,7 +414,7 @@ The suite covers: checked for message count, pending bytes, exact messages, decoded status frames, the per-function histogram, and the resulting rig/amp/cab names. - **Unit tests** for each module, and async tests that drive `Session` and - `DeviceModel` against an in-process stand-in device (`tests/fake_device.py`). + `DeviceModel` against an in-process stand-in device (`libkp.testing`, shipped with the package). ## Provenance diff --git a/python/examples/homeassistant/README.md b/python/examples/homeassistant/README.md deleted file mode 100644 index 42a339e..0000000 --- a/python/examples/homeassistant/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# Kemper Profiler — Home Assistant integration - -A custom integration that puts a Kemper Profiler on the local network into -Home Assistant: what rig is loaded, and whether anyone is playing through it. - -It is an **example** of using `libkp` in an application, and it is a real -integration: it holds one MIDI3 session for as long as Home Assistant runs, -takes everything it shows from what the device pushes unrequested, and never -polls the device or reconnects in a loop. - -A Profiler is identified by the **serial number** it advertises, not by its -address. Every setup broadcasts once to ask where that serial is now, so a -device whose DHCP lease moves it to another address is followed automatically: -the entry, the device and all five entities stay exactly as they were, history -included. Discovery finding nothing — the port held by Rig Manager, a quiet -network — is not an error; the last known address is used as it stands. - -``` -Kemper Profiler -├─ sensor._rig Rig name "Crunchy Vox" -├─ sensor._amp Amp name "Vintage Twin" -├─ sensor._cabinet Cabinet name "2x12 Alnico" -├─ binary_sensor._active Playing? on / off -└─ sensor._last_activity Last activity timestamp -``` - -## The entities - -| Entity | What it is | -|---|---| -| `sensor` Rig / Amp / Cabinet | The names the device pushes on a rig change. They follow the front panel, a MIDI controller, Rig Manager — anything that loads a rig. | -| `binary_sensor` Active | On while signal is passing through the rig. See below. | -| `sensor` Last activity | When signal was last heard. While *Active* is on it is when the current session began; when *Active* goes off it is the moment of the last note. | - -Everything else the device says — the effect slots, the tempo, the volumes, -the tuner, the bank preview, both channels' states — is in the integration's -**diagnostics** download rather than in entities. Adding an entity for any of -it is one row in the table in `sensor.py`. - -### Activity detection - -The Profiler pushes a meter frame about twenty times a second. Writing an -entity per frame would put 72,000 states an hour into the recorder to say -"someone is playing", so the meter lane is read by one plain callback that -compares a single 14-bit integer per frame and writes Home Assistant state -only when the answer changes: **two state writes per playing session**, -however long the session runs. - -The level it reads is the **rig output** meter — after the rig's own volume, -before the master/monitor/headphone volumes — so a rig turned down reads -quiet, but practising with the monitors off still reads as playing. - -Two options (Settings → Devices & services → Kemper Profiler → Configure): - -- **Quiet window** — how long the output must stay below the threshold before - *Active* turns off. Default 5 minutes. -- **Level threshold** — how loud counts as playing, as a percentage of full - scale. Default 2%. - -Saving them retunes the running detector; it does **not** reconnect to the -device. - -### Losing the connection - -When the stream ends — the amp switched off, the network dropped — the -integration does not redial the address it was using, because that address is -the part that can change. It reloads the config entry instead, which starts -again at discovery: find the serial, follow it to wherever it is now, connect -once. A Profiler that is simply off fails that setup and Home Assistant retries -on its own widening schedule; a session that ends within a minute of opening -waits half a minute before reloading, so nothing can spin. - -## Install - -Build the bundle and copy it into your Home Assistant configuration -directory, next to `configuration.yaml`: - -```sh -uv run python build.py # dist/custom_components/kemper + a zip -uv run python build.py --install ~/homeassistant -``` - -For a Home Assistant OS or supervised install, take -`dist/kemper-.zip` and unpack it into the configuration directory -with the **Samba share**, **Terminal & SSH**, or **File editor** add-on — its -paths are already `custom_components/kemper/…`. - -Then: - -1. Restart Home Assistant. -2. **Settings → Devices & services → Add integration → "Kemper Profiler"**. -3. The flow broadcasts for Profilers on the LAN and lists what answers. If - nothing answers — Rig Manager holds the discovery port exclusively, and so - does a running `meters` example — choose *Enter a host manually* and give - the Profiler's IP address. - -The bundle vendors `libkp` itself, so the integration has no `pip` -requirements and works on an install with no internet access. - -## Development - -The integration is developed against the library beside it: -`custom_components/kemper/libkp` is a relative symlink to `python/src/libkp`, -and the integration imports it as `from .libkp import …`. The same code -therefore runs against the working tree here and against the vendored copy in -a bundle — `build.py` dereferences the symlink and copies the library in. - -```sh -uv sync # a Python 3.14 environment with Home Assistant 2026.8 -uv run ruff check . -uv run ruff format --check . -uv run pytest -q -uv run python build.py -``` - -The tests are end-to-end over a real loopback socket: they drive libkp's own -`FakeDevice` (`python/tests/fake_device.py`), push the same bytes a Profiler -pushes, and assert on entity states. Nothing below the config entry is mocked -except the discovery broadcast. diff --git a/python/examples/homeassistant/build.py b/python/examples/homeassistant/build.py deleted file mode 100644 index b36cb28..0000000 --- a/python/examples/homeassistant/build.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -"""Bundle the integration into something a Home Assistant config directory takes. - -In the source tree ``custom_components/kemper/libkp`` is a symlink to the -library beside it, so the integration runs against the working copy with no -install step and no copy to keep in sync. Home Assistant would not tolerate -that symlink in a config directory, so the bundle **dereferences** it: the -library is copied in as an ordinary package, and the shipped integration -depends on nothing but the standard library. - -Usage:: - - python build.py # dist/custom_components/kemper + the zip - python build.py --install ~/homeassistant # copy into a config directory -""" - -from __future__ import annotations - -import argparse -import json -import shutil -import zipfile -from pathlib import Path - -HERE = Path(__file__).resolve().parent -SOURCE = HERE / "custom_components" / "kemper" -DIST = HERE / "dist" -DOMAIN = "kemper" - -#: Never shipped: bytecode caches, editor droppings, and the integration's own -#: tests, which import Home Assistant's test harness. -IGNORE = shutil.ignore_patterns("__pycache__", "*.py[co]", ".DS_Store", "tests") - - -def version() -> str: - """The version in the manifest — the one Home Assistant shows.""" - return json.loads((SOURCE / "manifest.json").read_text(encoding="utf-8"))["version"] - - -def build() -> Path: - """Write ``dist/custom_components/kemper`` and the zip beside it.""" - staged = DIST / "custom_components" / DOMAIN - if DIST.exists(): - shutil.rmtree(DIST) - staged.parent.mkdir(parents=True) - # symlinks=False is the point: the libkp symlink lands as a real directory. - shutil.copytree(SOURCE, staged, symlinks=False, ignore=IGNORE) - - archive = DIST / f"{DOMAIN}-{version()}.zip" - with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf: - for path in sorted(staged.rglob("*")): - if path.is_file(): - zf.write(path, path.relative_to(DIST)) - return archive - - -def install(config_dir: Path) -> Path: - """Replace ``/custom_components/kemper`` with the freshly built copy.""" - staged = DIST / "custom_components" / DOMAIN - target = config_dir / "custom_components" / DOMAIN - target.parent.mkdir(parents=True, exist_ok=True) - if target.exists(): - shutil.rmtree(target) - shutil.copytree(staged, target) - return target - - -def main() -> int: - """Build, and optionally install into a config directory.""" - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument( - "--install", - metavar="HA_CONFIG_DIR", - type=Path, - help="also copy the bundle into this Home Assistant configuration directory", - ) - args = parser.parse_args() - - archive = build() - staged = DIST / "custom_components" / DOMAIN - files = sum(1 for path in staged.rglob("*") if path.is_file()) - print(f"built {staged.relative_to(HERE)} ({files} files)") - print(f" {archive.relative_to(HERE)}") - - if args.install is not None: - config_dir = args.install.expanduser().resolve() - if not config_dir.is_dir(): - parser.error(f"{config_dir} is not a directory") - target = install(config_dir) - print(f"installed into {target}") - print("restart Home Assistant, then add the integration from Devices & services") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/python/examples/homeassistant/custom_components/kemper/__init__.py b/python/examples/homeassistant/custom_components/kemper/__init__.py deleted file mode 100644 index dcaceb1..0000000 --- a/python/examples/homeassistant/custom_components/kemper/__init__.py +++ /dev/null @@ -1,130 +0,0 @@ -"""The Kemper Profiler integration: one config entry, one device, one session. - -Setting an entry up opens exactly one MIDI3 stream to the Profiler and keeps -it. The device tolerates a session; what it does not tolerate is connection -*churn* (``docs/06``, ``docs/11``), so nothing here dials in a loop. - -**Where the device is** is decided fresh at every setup. The entry's identity -is the serial the Profiler advertises, not its address: an entry that knows a -serial broadcasts once, and if that serial answers from somewhere else the -entry is updated to the new address (and to the name and firmware version, -which change too) before anything is dialed. Discovery finding nothing — the -port held by Rig Manager, a quiet network, a device on another subnet — is not -an error; the stored address is used as it stands. - -**Losing the stream** therefore goes back through the same door instead of -through libkp's own redial: reconnecting to a remembered address would keep -dialing an address the device may have left. The coordinator asks for a reload, -setup rediscovers, and a device that is simply switched off fails with -:class:`ConfigEntryNotReady`, which is Home Assistant's own spaced retry. -""" - -from __future__ import annotations - -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import CONF_SERIAL, CONF_SW_VERSION -from .coordinator import KemperConfigEntry, KemperCoordinator -from .discovery import async_find_serial -from .libkp import ConnectOptions, ControlPolicy, DeviceModel, LibKPError -from .libkp.protocol import PORT - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] - -#: How the integration connects. The CBOR control channel is deliberately off: -#: the only thing it adds over the stream is the morph position, which nothing -#: here surfaces, and it would cost the device a second socket for as long as -#: Home Assistant runs. No reconnect policy either — see the module docstring: -#: coming back is a reload, so that the address is looked up again first. -CONNECT_CONTROL = ControlPolicy.OFF - - -async def async_locate(hass: HomeAssistant, entry: KemperConfigEntry) -> str: - """The address to dial, after asking the network where the serial is. - - Returns the stored host unchanged when the entry predates serial-keying, - when nothing answers, or when the device is where it was; otherwise the - entry is updated in place — the address, and the name and version, which a - firmware update or a rename changes just as quietly. - """ - host: str = entry.data[CONF_HOST] - serial: str | None = entry.data.get(CONF_SERIAL) - if not serial: - return host - - found = await async_find_serial(serial) - if found is None: - return host - - updates = { - key: value - for key, value in ( - (CONF_HOST, found.host), - (CONF_NAME, found.name), - (CONF_SW_VERSION, found.version), - ) - if entry.data.get(key) != value - } - if not updates: - return host - if CONF_HOST in updates: - _LOGGER.info( - "Profiler %s answered from %s instead of %s; following it", - serial, - found.host, - host, - ) - hass.config_entries.async_update_entry(entry, data={**entry.data, **updates}) - return found.host - - -async def async_setup_entry(hass: HomeAssistant, entry: KemperConfigEntry) -> bool: - """Find the Profiler, connect to it, and bring its entities up.""" - host = await async_locate(hass, entry) - options = ConnectOptions(port=entry.data.get(CONF_PORT, PORT), control=CONNECT_CONTROL) - try: - model = await DeviceModel.connect(host, options=options) - except (LibKPError, OSError) as err: - raise ConfigEntryNotReady(f"could not connect to the Profiler at {host}: {err}") from err - - coordinator = KemperCoordinator(hass, entry, model) - entry.runtime_data = coordinator - try: - await coordinator.async_start() - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - except Exception: - # Whatever went wrong, the socket does not get to outlive the attempt. - await coordinator.async_shutdown() - raise - - entry.async_on_unload(entry.add_update_listener(async_options_updated)) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: KemperConfigEntry) -> bool: - """Tear the entities down and hang up on the device.""" - unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unloaded: - await entry.runtime_data.async_shutdown() - return unloaded - - -async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Apply changed options in place — never by reloading the entry. - - A reload would close the session and open another one, which is a real cost - to the device; the two options that exist only steer the activity detector, - and it can be retuned while it runs. The same listener sees the address - updates :func:`async_locate` makes, which need no action at all: they are - already what the running session was dialed with. - """ - coordinator: KemperCoordinator | None = getattr(entry, "runtime_data", None) - if coordinator is not None: - coordinator.apply_options() diff --git a/python/examples/homeassistant/custom_components/kemper/activity.py b/python/examples/homeassistant/custom_components/kemper/activity.py deleted file mode 100644 index 0d7f127..0000000 --- a/python/examples/homeassistant/custom_components/kemper/activity.py +++ /dev/null @@ -1,199 +0,0 @@ -"""The activity detector — the one consumer of the device's fast lane. - -The Profiler pushes a meter frame about twenty times a second, unrequested, -for as long as the stream is open (``docs/07``). That rate is right for a -level meter and wrong for Home Assistant: an entity written per frame would -put 72,000 states an hour into the recorder to say "someone is playing". - -So the meter lane is read here and nowhere else, and it produces exactly two -state writes per playing session however long the session runs: - -- a plain callback on the model's event stream does two integer comparisons - per frame and stores a timestamp — no awaits, no state writes, no work that - scales with how long the note lasts; -- the first crossing of the threshold flips the detector **on** and arms one - timer; -- when that timer fires, the detector settles **off** if the window has gone - by with no crossing, and otherwise re-arms itself for the remainder. The - timer is never re-armed per sample, so a two-hour rehearsal costs the same - one timer a single chord does. - -The level read is ``rig_out_level`` (meter v6), the tap *after* rig volume. -``docs/07`` describes the four candidates: the strobe fields say nothing about -level, ``stack_level`` (v4) ignores rig volume — so a rig deliberately turned -down still reads loud — and ``loudness`` (v9) is a slow RMS that both lags the -first note and tails off after the last. v6 follows playing dynamics -immediately, respects the rig's own volume, and is deliberately blind to the -main/monitor/headphone knobs, so turning the monitors down to practise -quietly does not read as "stopped playing". -""" - -from __future__ import annotations - -from collections.abc import Callable -from datetime import datetime - -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.event import async_call_later -from homeassistant.util import dt as dt_util - -from .libkp import _generated as gen -from .libkp.model import DeviceModel -from .libkp.state import DeviceEvent, Status - - -def raw_threshold(percent: float) -> int: - """The 14-bit meter value a ``percent``-of-full-scale threshold means.""" - return max(0, min(gen.FULL_SCALE, round(gen.FULL_SCALE * percent / 100.0))) - - -class ActivityDetector: - """Whether sound is currently passing through the rig, and when it last did. - - Owns its own subscription to the model (:meth:`start` / :meth:`stop`) and - tells its listeners only when one of those two answers changes, which is - what the ``active`` binary sensor and the ``last_activity`` sensor write on. - """ - - def __init__( - self, - hass: HomeAssistant, - model: DeviceModel, - *, - window: float, - threshold: float, - ) -> None: - self._hass = hass - self._model = model - #: How long without a crossing ends a session, in seconds. - self._window = window - #: The threshold as the meter lane reports it: a 14-bit integer, so the - #: per-frame test is one comparison and no arithmetic. - self._threshold = raw_threshold(threshold) - self._active = False - self._last_signal: datetime | None = None - self._last_activity: datetime | None = None - self._cancel_timer: CALLBACK_TYPE | None = None - self._listeners: list[Callable[[], None]] = [] - self._attached = False - - # -- what the entities read ------------------------------------------ - - @property - def active(self) -> bool: - """True while the threshold has been crossed inside the window.""" - return self._active - - @property - def window(self) -> float: - """The quiet window currently in force, in seconds.""" - return self._window - - @property - def threshold(self) -> int: - """The level threshold currently in force, as the meter reports it.""" - return self._threshold - - @property - def last_activity(self) -> datetime | None: - """When signal was last seen, as of the most recent transition. - - While :attr:`active` is on this is when the current session began; - when it goes off it becomes the moment of the last crossing — the last - note heard — and stays there until the next session starts. - """ - return self._last_activity - - @callback - def add_listener(self, callback_: Callable[[], None]) -> CALLBACK_TYPE: - """Register a callback for transitions; returns its remover.""" - self._listeners.append(callback_) - - @callback - def remove() -> None: - if callback_ in self._listeners: - self._listeners.remove(callback_) - - return remove - - # -- lifecycle ------------------------------------------------------- - - @callback - def start(self) -> None: - """Begin watching the model's events.""" - if self._attached: - return - self._model.add_event_listener(self._on_event) - self._attached = True - - @callback - def stop(self) -> None: - """Stop watching and disarm the timer. Idempotent.""" - if self._attached: - self._model.remove_event_listener(self._on_event) - self._attached = False - self._disarm() - - @callback - def update_options(self, *, window: float, threshold: float) -> None: - """Apply new options in place — no reconnect, no reload. - - Every socket to the device costs it something (``docs/11``), so - changing a number in the options form must not cost a session. A - shorter window is honoured immediately: the armed timer is re-evaluated - against the new one, which can settle the detector off on the spot. - """ - self._window = window - self._threshold = raw_threshold(threshold) - if self._active: - self._disarm() - self._expire(dt_util.utcnow()) - - # -- the fast lane --------------------------------------------------- - - @callback - def _on_event(self, event: DeviceEvent) -> None: - """Called for every event the model decodes, ~20 Hz of them meters. - - Keep this trivial: it runs inside the model's ingest path. - """ - if not isinstance(event, Status): - return - if event.status.rig_out_level <= self._threshold: - return - self._last_signal = dt_util.utcnow() - if self._active: - return - self._active = True - self._last_activity = self._last_signal - self._arm(self._window) - self._notify() - - @callback - def _arm(self, delay: float) -> None: - self._cancel_timer = async_call_later(self._hass, delay, self._expire) - - @callback - def _disarm(self) -> None: - if self._cancel_timer is not None: - self._cancel_timer() - self._cancel_timer = None - - @callback - def _expire(self, now: datetime) -> None: - """The window may have run out — settle off, or re-arm for the rest.""" - self._cancel_timer = None - if not self._active or self._last_signal is None: - return - idle = (now - self._last_signal).total_seconds() - if idle < self._window: - self._arm(self._window - idle) - return - self._active = False - self._last_activity = self._last_signal - self._notify() - - @callback - def _notify(self) -> None: - for listener in list(self._listeners): - listener() diff --git a/python/examples/homeassistant/custom_components/kemper/binary_sensor.py b/python/examples/homeassistant/custom_components/kemper/binary_sensor.py deleted file mode 100644 index f8275ab..0000000 --- a/python/examples/homeassistant/custom_components/kemper/binary_sensor.py +++ /dev/null @@ -1,43 +0,0 @@ -"""The ``active`` binary sensor: is anything actually coming out of the rig. - -It reads nothing itself — :class:`~.activity.ActivityDetector` owns the meter -lane and tells this entity when the answer changes, which is twice per playing -session. -""" - -from __future__ import annotations - -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import KemperConfigEntry, KemperCoordinator -from .entity import KemperEntity - - -async def async_setup_entry( - hass: HomeAssistant, - entry: KemperConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the Profiler's binary sensors.""" - async_add_entities([KemperActiveBinarySensor(entry.runtime_data)]) - - -class KemperActiveBinarySensor(KemperEntity, BinarySensorEntity): - """On while the rig has passed signal inside the configured window.""" - - _attr_translation_key = "active" - - def __init__(self, coordinator: KemperCoordinator) -> None: - super().__init__(coordinator, "active") - - async def async_added_to_hass(self) -> None: - """Follow the detector as well as the coordinator.""" - await super().async_added_to_hass() - self.async_on_remove(self.coordinator.activity.add_listener(self.async_write_ha_state)) - - @property - def is_on(self) -> bool: - """Whether the detector currently reads as playing.""" - return self.coordinator.activity.active diff --git a/python/examples/homeassistant/custom_components/kemper/config_flow.py b/python/examples/homeassistant/custom_components/kemper/config_flow.py deleted file mode 100644 index 2a5212d..0000000 --- a/python/examples/homeassistant/custom_components/kemper/config_flow.py +++ /dev/null @@ -1,218 +0,0 @@ -"""The config flow: find the Profiler if we can, ask for it if we cannot. - -Discovery comes first because the Profiler answers a UDP broadcast with its -name, serial and firmware version — everything the device registry wants — -without costing it a TCP session. The port is exclusive (one process at a -time), so Rig Manager or a running meters example holding it is an ordinary -outcome, not an error: the flow falls through to a host/port form. - -A manually entered host is checked with exactly **one** session, opened and -closed, and then asked who it is with a short directed poll — so a hand-added -Profiler is keyed by its serial too, and survives moving to another address. -Only a device that answers no poll at all is keyed by its host. There is no -retry loop anywhere in this file; the device does not tolerate connection -churn (``docs/06``). -""" - -from __future__ import annotations - -from typing import Any - -import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT -from homeassistant.core import callback -from homeassistant.helpers.selector import ( - NumberSelector, - NumberSelectorConfig, - NumberSelectorMode, - SelectOptionDict, - SelectSelector, - SelectSelectorConfig, - SelectSelectorMode, - TextSelector, -) - -from .const import ( - CONF_ACTIVITY_THRESHOLD, - CONF_ACTIVITY_WINDOW, - CONF_SERIAL, - CONF_SW_VERSION, - DEFAULT_ACTIVITY_THRESHOLD, - DEFAULT_ACTIVITY_WINDOW, - DEFAULT_NAME, - DOMAIN, -) -from .discovery import Found, async_discover, async_identify -from .libkp import ConnectOptions, ControlPolicy, DeviceModel, LibKPError, SyncStrategy -from .libkp.protocol import PORT - -#: The sentinel option that leaves the device list for the manual form. -MANUAL = "manual" - - -async def async_check(host: str, port: int) -> None: - """Prove a host is a Profiler: one session, opened and closed. - - Nothing is requested on it — the point is the handshake, and the burst of - reads belongs to the entry that goes on to hold the session. - """ - model = await DeviceModel.connect( - host, - options=ConnectOptions(port=port, control=ControlPolicy.OFF, sync=SyncStrategy.OFF), - ) - await model.close() - - -class KemperConfigFlow(ConfigFlow, domain=DOMAIN): - """Add one Profiler.""" - - VERSION = 1 - - def __init__(self) -> None: - self._found: list[Found] = [] - - async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - """Look for Profilers, then pick one or type one in.""" - self._found = await async_discover() - if not self._found: - return await self.async_step_manual() - return await self.async_step_pick() - - async def async_step_pick(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - """Choose among the Profilers that answered.""" - if user_input is not None: - chosen = user_input[CONF_DEVICE] - if chosen == MANUAL: - return await self.async_step_manual() - found = next(device for device in self._found if device.host == chosen) - await self.async_set_unique_id(found.serial or found.host) - self._abort_if_unique_id_configured(updates={CONF_HOST: found.host}) - return self.async_create_entry( - title=found.name, - data={ - CONF_HOST: found.host, - CONF_PORT: PORT, - CONF_NAME: found.name, - CONF_SERIAL: found.serial, - CONF_SW_VERSION: found.version, - }, - ) - - options = [ - SelectOptionDict(value=device.host, label=f"{device.name} ({device.host})") - for device in self._found - ] - options.append(SelectOptionDict(value=MANUAL, label="Enter a host manually")) - return self.async_show_form( - step_id="pick", - data_schema=vol.Schema( - { - vol.Required(CONF_DEVICE): SelectSelector( - SelectSelectorConfig(options=options, mode=SelectSelectorMode.LIST) - ) - } - ), - ) - - async def async_step_manual(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - """Type in a host that discovery could not find.""" - errors: dict[str, str] = {} - if user_input is not None: - host = user_input[CONF_HOST].strip() - port = int(user_input[CONF_PORT]) - try: - await async_check(host, port) - except LibKPError, OSError: - errors["base"] = "cannot_connect" - except Exception: # the form must survive anything the stack raises - errors["base"] = "unknown" - else: - # It is a Profiler; now ask it who it is, so the entry is keyed - # by the serial rather than by an address that can change. - found = await async_identify(host) or Found( - host=host, name=DEFAULT_NAME, serial=None, version=None - ) - await self.async_set_unique_id(found.serial or host) - self._abort_if_unique_id_configured(updates={CONF_HOST: host}) - return self.async_create_entry( - title=found.name, - data={ - CONF_HOST: host, - CONF_PORT: port, - CONF_NAME: found.name, - CONF_SERIAL: found.serial, - CONF_SW_VERSION: found.version, - }, - ) - - suggested = user_input or {CONF_PORT: PORT} - return self.async_show_form( - step_id="manual", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=suggested.get(CONF_HOST, "")): TextSelector(), - vol.Required(CONF_PORT, default=suggested.get(CONF_PORT, PORT)): vol.All( - vol.Coerce(int), vol.Range(min=1, max=65535) - ), - } - ), - errors=errors, - ) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> KemperOptionsFlow: - """The two knobs the activity detector has.""" - return KemperOptionsFlow() - - -class KemperOptionsFlow(OptionsFlow): - """How loud, and for how long, counts as playing. - - Saving these does **not** reload the entry: the integration applies them to - the running detector, so tuning them never costs the device a session. - """ - - async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - """Show and store the detector's window and threshold.""" - if user_input is not None: - return self.async_create_entry(data=user_input) - - options = self.config_entry.options - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Required( - CONF_ACTIVITY_WINDOW, - default=options.get(CONF_ACTIVITY_WINDOW, DEFAULT_ACTIVITY_WINDOW), - ): NumberSelector( - NumberSelectorConfig( - min=1, - max=120, - step=1, - unit_of_measurement="min", - mode=NumberSelectorMode.BOX, - ) - ), - vol.Required( - CONF_ACTIVITY_THRESHOLD, - default=options.get(CONF_ACTIVITY_THRESHOLD, DEFAULT_ACTIVITY_THRESHOLD), - ): NumberSelector( - NumberSelectorConfig( - min=0, - max=100, - step=0.5, - unit_of_measurement="%", - mode=NumberSelectorMode.BOX, - ) - ), - } - ), - ) diff --git a/python/examples/homeassistant/custom_components/kemper/const.py b/python/examples/homeassistant/custom_components/kemper/const.py deleted file mode 100644 index 248d8e6..0000000 --- a/python/examples/homeassistant/custom_components/kemper/const.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Constants shared by the Kemper Profiler integration.""" - -from __future__ import annotations - -DOMAIN = "kemper" - -#: What the device is called before discovery has told us otherwise. -DEFAULT_NAME = "Kemper Profiler" -#: Device-registry identity, fixed for every Profiler. -MANUFACTURER = "Kemper" -MODEL = "Profiler" - -#: Entry data keys beyond ``CONF_HOST`` / ``CONF_PORT`` / ``CONF_NAME``: what -#: discovery told us about the device, kept so the device registry can show it -#: without re-polling. -CONF_SERIAL = "serial" -CONF_SW_VERSION = "sw_version" - -#: How long a broadcast poll listens for replies, in seconds. -DISCOVERY_SECONDS = 3.0 -#: How long a poll aimed at one known address listens: the device is either -#: there and answers at once, or it is not. -DIRECTED_DISCOVERY_SECONDS = 1.5 - -#: Options-flow keys and their defaults. The window is in minutes and the -#: threshold in percent of the meter lane's full scale, because those are the -#: units the person filling in the form thinks in; the detector converts. -CONF_ACTIVITY_WINDOW = "activity_window" -CONF_ACTIVITY_THRESHOLD = "activity_threshold" -DEFAULT_ACTIVITY_WINDOW = 5.0 -DEFAULT_ACTIVITY_THRESHOLD = 2.0 diff --git a/python/examples/homeassistant/custom_components/kemper/coordinator.py b/python/examples/homeassistant/custom_components/kemper/coordinator.py deleted file mode 100644 index 1193b7c..0000000 --- a/python/examples/homeassistant/custom_components/kemper/coordinator.py +++ /dev/null @@ -1,191 +0,0 @@ -"""The push coordinator: one :class:`DeviceModel`, one state tree, one device. - -libkp's model is already a store — it holds the device state and hands out a -fresh snapshot whenever *slow* state changes, coalesced to at most one per -ingested chunk. So there is nothing to poll here and no update interval: the -coordinator is a :class:`DataUpdateCoordinator` whose data arrives from a -background task that does nothing but drain the model's snapshot queue. - -That task is also where a lost stream is noticed. libkp can redial one on its -own, and this integration deliberately does not ask it to: the model would -redial the address it was given, and the whole point of keying an entry by the -Profiler's serial is that the address is the part that changes. So a loss ends -the session and asks Home Assistant to reload the entry, which starts again -from discovery. A session that ends almost as soon as it began is treated as a -device that is not really there and the reload waits -:data:`RELOAD_DELAY_SECONDS`, so nothing can spin setup in a loop. - -The fast lane (meters, beat pulse, tuner deviance) never reaches this class. -It is read only by :class:`~.activity.ActivityDetector`, which turns it into -two state writes per playing session. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util import dt as dt_util - -from .activity import ActivityDetector -from .const import ( - CONF_ACTIVITY_THRESHOLD, - CONF_ACTIVITY_WINDOW, - CONF_SW_VERSION, - DEFAULT_ACTIVITY_THRESHOLD, - DEFAULT_ACTIVITY_WINDOW, - DEFAULT_NAME, - DOMAIN, - MANUFACTURER, - MODEL, -) -from .libkp.model import DeviceModel -from .libkp.state import Connection, DeviceState - -_LOGGER = logging.getLogger(__name__) - -#: A session that ends sooner than this after it opened says the device is not -#: really available, whatever its handshake said. -SHORT_SESSION_SECONDS = 60.0 -#: How long such a session waits before the entry is reloaded. A healthy -#: session that ends — the amp switched off after a rehearsal — reloads at once. -RELOAD_DELAY_SECONDS = 30.0 - -#: The entry, typed by what :attr:`ConfigEntry.runtime_data` holds. -type KemperConfigEntry = ConfigEntry[KemperCoordinator] - - -def activity_window(entry: ConfigEntry) -> float: - """The configured quiet window, in seconds (the form asks for minutes).""" - return float(entry.options.get(CONF_ACTIVITY_WINDOW, DEFAULT_ACTIVITY_WINDOW)) * 60.0 - - -def activity_threshold(entry: ConfigEntry) -> float: - """The configured level threshold, in percent of the meter full scale.""" - return float(entry.options.get(CONF_ACTIVITY_THRESHOLD, DEFAULT_ACTIVITY_THRESHOLD)) - - -class KemperCoordinator(DataUpdateCoordinator[DeviceState]): - """Publishes the model's slow-lane snapshots to the entity layer.""" - - config_entry: KemperConfigEntry - - def __init__(self, hass: HomeAssistant, entry: KemperConfigEntry, model: DeviceModel) -> None: - super().__init__( - hass, - _LOGGER, - config_entry=entry, - name=f"{DOMAIN} {entry.data[CONF_HOST]}", - update_interval=None, - ) - self.model = model - self.activity = ActivityDetector( - hass, - model, - window=activity_window(entry), - threshold=activity_threshold(entry), - ) - self._task: asyncio.Task[None] | None = None - self._reload_timer: CALLBACK_TYPE | None = None - self._opened = dt_util.utcnow() - #: Set once the entry is being torn down, so the disconnection the - #: teardown itself causes is not mistaken for the device going away. - self._closing = False - - @property - def reload_pending(self) -> bool: - """Whether a lost stream is waiting to reload the entry.""" - return self._reload_timer is not None - - @property - def device_id(self) -> str: - """The device-registry identifier: the serial when discovery knew it, - else the host, else the entry — stable across restarts either way.""" - entry = self.config_entry - return entry.unique_id or entry.entry_id - - @property - def device_info(self) -> DeviceInfo: - """One device per config entry: the Profiler itself.""" - entry = self.config_entry - return DeviceInfo( - identifiers={(DOMAIN, self.device_id)}, - manufacturer=MANUFACTURER, - model=MODEL, - name=entry.data.get(CONF_NAME) or DEFAULT_NAME, - sw_version=entry.data.get(CONF_SW_VERSION), - ) - - async def async_start(self) -> None: - """Seed the first snapshot, attach the detector, start listening.""" - self._opened = dt_util.utcnow() - self.async_set_updated_data(self.model.state()) - self.activity.start() - self._task = self.config_entry.async_create_background_task( - self.hass, self._listen(), name=f"{DOMAIN} {self.config_entry.data[CONF_HOST]} state" - ) - - async def _listen(self) -> None: - """Drain the model's store; every snapshot is an entity update. - - The loop ends when the device goes away, which is the one thing a - snapshot can say that this class acts on rather than passes along. - """ - queue = self.model.subscribe() - try: - while True: - state = await queue.get() - self.async_set_updated_data(state) - if state.connection is Connection.DISCONNECTED: - self._schedule_reload() - return - finally: - self.model.unsubscribe(queue) - - @callback - def _schedule_reload(self) -> None: - """Ask for a reload, so the way back starts at discovery.""" - if self._closing or self._reload_timer is not None: - return - session = (dt_util.utcnow() - self._opened).total_seconds() - delay = 0.0 if session >= SHORT_SESSION_SECONDS else RELOAD_DELAY_SECONDS - _LOGGER.info( - "Lost the stream to the Profiler after %.0f s; reloading in %.0f s to find it again", - session, - delay, - ) - self._reload_timer = async_call_later(self.hass, delay, self._reload) - - @callback - def _reload(self, _now: object) -> None: - self._reload_timer = None - self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) - - async def async_shutdown(self) -> None: - """Stop listening and hang up. The device sees one clean disconnect.""" - self._closing = True - if self._reload_timer is not None: - self._reload_timer() - self._reload_timer = None - await super().async_shutdown() - self.activity.stop() - if self._task is not None: - self._task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._task - self._task = None - await self.model.close() - - def apply_options(self) -> None: - """Re-read the options the detector uses, without touching the socket.""" - entry = self.config_entry - self.activity.update_options( - window=activity_window(entry), threshold=activity_threshold(entry) - ) diff --git a/python/examples/homeassistant/custom_components/kemper/diagnostics.py b/python/examples/homeassistant/custom_components/kemper/diagnostics.py deleted file mode 100644 index 40ff4e9..0000000 --- a/python/examples/homeassistant/custom_components/kemper/diagnostics.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Diagnostics: the whole state tree, which is the debugging window. - -Only five entities are exposed, but the model knows far more than that — the -effect slots, the tempo, the volumes, the tuner, the bank preview, both -channels' states. Dumping the tree here means a bug report carries everything -the device said without any of it having to become an entity first. -""" - -from __future__ import annotations - -from dataclasses import fields, is_dataclass -from enum import Enum -from typing import Any - -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant - -from .const import CONF_SERIAL -from .coordinator import KemperConfigEntry - -TO_REDACT = {CONF_HOST, CONF_SERIAL} - - -def plain(value: Any) -> Any: - """A JSON-friendly copy of a state tree: dataclasses to dicts, enums to - their values, private bookkeeping fields left out.""" - if is_dataclass(value) and not isinstance(value, type): - return { - field.name: plain(getattr(value, field.name)) - for field in fields(value) - if not field.name.startswith("_") - } - if isinstance(value, Enum): - return value.value - if isinstance(value, (list, tuple, set)): - return [plain(item) for item in value] - if isinstance(value, dict): - return {str(key): plain(item) for key, item in value.items()} - return value - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: KemperConfigEntry -) -> dict[str, Any]: - """Everything this integration knows about one Profiler.""" - coordinator = entry.runtime_data - detector = coordinator.activity - last_activity = detector.last_activity - return { - "entry": { - "data": async_redact_data(dict(entry.data), TO_REDACT), - "options": dict(entry.options), - }, - "activity": { - "active": detector.active, - "last_activity": None if last_activity is None else last_activity.isoformat(), - }, - "state": plain(coordinator.model.state()), - } diff --git a/python/examples/homeassistant/custom_components/kemper/discovery.py b/python/examples/homeassistant/custom_components/kemper/discovery.py deleted file mode 100644 index 026ec39..0000000 --- a/python/examples/homeassistant/custom_components/kemper/discovery.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Finding a Profiler, and finding it again when its address moves. - -The Profiler answers a UDP broadcast with its name, **serial** and firmware -version, and costs itself nothing to do it — no session, no handshake. That -makes the serial the device's real identity and the IP address merely where it -happens to be today: a DHCP lease expires over a weekend and the same amp comes -back on a different address. - -So discovery is used in two places, and both live here rather than in the -config flow: once when a Profiler is added, and once per setup to ask "where is -serial X now". Every poll is a single 3-second listen — there is no loop, and a -port that another program holds is an ordinary answer of "nothing found". -""" - -from __future__ import annotations - -from collections.abc import Iterable -from dataclasses import dataclass - -from .const import DEFAULT_NAME, DIRECTED_DISCOVERY_SECONDS, DISCOVERY_SECONDS -from .libkp import DiscoveryOptions, DiscoveryPort, LibKPError -from .libkp.discovery import Reply - - -@dataclass(frozen=True, slots=True) -class Found: - """One Profiler that answered a discovery poll.""" - - host: str - name: str - serial: str | None - version: str | None - - @classmethod - def from_reply(cls, reply: Reply) -> Found: - """What a raw reply says about the device that sent it.""" - return cls( - host=reply.ip, - name=reply.name or DEFAULT_NAME, - serial=reply.serial, - version=reply.version, - ) - - -async def async_discover( - *, listen_for: float = DISCOVERY_SECONDS, targets: Iterable[str] | None = None -) -> list[Found]: - """Poll for Profilers. An unavailable port or a failed poll means none. - - ``targets`` adds explicit unicast destinations to the broadcast, which is - how a device on the other side of a router — one no broadcast reaches — can - still be asked to identify itself. - """ - try: - with DiscoveryPort.acquire() as port: - replies = await port.poll( - DiscoveryOptions(listen_for=listen_for, extra_targets=list(targets or ())) - ) - except LibKPError, OSError: - return [] - return [Found.from_reply(reply) for reply in replies] - - -async def async_find_serial(serial: str) -> Found | None: - """Where the Profiler with this serial is now, if it answers at all.""" - for found in await async_discover(): - if found.serial == serial: - return found - return None - - -async def async_identify(host: str) -> Found | None: - """Ask one known address who it is: a short, directed poll. - - Used after a manually entered host has been proved to be a Profiler, so - that a hand-added device is keyed by its serial like a discovered one and - survives moving to another address. - """ - for found in await async_discover(listen_for=DIRECTED_DISCOVERY_SECONDS, targets=[host]): - if found.host == host: - return found - return None diff --git a/python/examples/homeassistant/custom_components/kemper/entity.py b/python/examples/homeassistant/custom_components/kemper/entity.py deleted file mode 100644 index 436a346..0000000 --- a/python/examples/homeassistant/custom_components/kemper/entity.py +++ /dev/null @@ -1,30 +0,0 @@ -"""The base entity: device identity, naming and availability in one place.""" - -from __future__ import annotations - -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .coordinator import KemperCoordinator -from .libkp.state import Connection - -#: The connection states in which what the entities show is live. A degraded -#: connection is still a connection: the stream — everything these entities -#: read — is open, and only the optional control channel is missing. -LIVE = (Connection.CONNECTED, Connection.DEGRADED) - - -class KemperEntity(CoordinatorEntity[KemperCoordinator]): - """One reading from one Profiler.""" - - _attr_has_entity_name = True - - def __init__(self, coordinator: KemperCoordinator, key: str) -> None: - super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.device_id}_{key}" - self._attr_device_info = coordinator.device_info - - @property - def available(self) -> bool: - """Available while the stream is up — the tree goes stale without it.""" - state = self.coordinator.data - return super().available and state is not None and state.connection in LIVE diff --git a/python/examples/homeassistant/custom_components/kemper/icons.json b/python/examples/homeassistant/custom_components/kemper/icons.json deleted file mode 100644 index 14944be..0000000 --- a/python/examples/homeassistant/custom_components/kemper/icons.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "entity": { - "binary_sensor": { - "active": { - "default": "mdi:guitar-electric", - "state": { - "off": "mdi:guitar-electric", - "on": "mdi:music-note" - } - } - }, - "sensor": { - "rig_name": { - "default": "mdi:tune-vertical" - }, - "amp_name": { - "default": "mdi:amplifier" - }, - "cabinet_name": { - "default": "mdi:speaker" - }, - "last_activity": { - "default": "mdi:clock-outline" - } - } - } -} diff --git a/python/examples/homeassistant/custom_components/kemper/libkp b/python/examples/homeassistant/custom_components/kemper/libkp deleted file mode 120000 index 454fbc0..0000000 --- a/python/examples/homeassistant/custom_components/kemper/libkp +++ /dev/null @@ -1 +0,0 @@ -../../../../src/libkp \ No newline at end of file diff --git a/python/examples/homeassistant/custom_components/kemper/manifest.json b/python/examples/homeassistant/custom_components/kemper/manifest.json deleted file mode 100644 index c9c0fc6..0000000 --- a/python/examples/homeassistant/custom_components/kemper/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "kemper", - "name": "Kemper Profiler", - "codeowners": ["@gotwalt"], - "config_flow": true, - "dependencies": [], - "documentation": "https://github.com/gotwalt/libkp/tree/main/python/examples/homeassistant", - "integration_type": "device", - "iot_class": "local_push", - "issue_tracker": "https://github.com/gotwalt/libkp/issues", - "requirements": [], - "version": "0.1.0" -} diff --git a/python/examples/homeassistant/custom_components/kemper/sensor.py b/python/examples/homeassistant/custom_components/kemper/sensor.py deleted file mode 100644 index 5fb2f41..0000000 --- a/python/examples/homeassistant/custom_components/kemper/sensor.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Sensors: what is loaded on the Profiler, and when it last made a sound. - -The three name sensors are a table of :class:`SensorEntityDescription` rows -with a ``value_fn`` over the state tree, so the tempo, the volumes, the morph -position or an effect slot's type is one row each whenever they are wanted. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import KemperConfigEntry, KemperCoordinator -from .entity import KemperEntity -from .libkp.state import DeviceState - - -@dataclass(frozen=True, kw_only=True) -class KemperSensorEntityDescription(SensorEntityDescription): - """A sensor described by where it reads from in the state tree.""" - - value_fn: Callable[[DeviceState], str | None] - - -SENSORS: tuple[KemperSensorEntityDescription, ...] = ( - KemperSensorEntityDescription( - key="rig_name", - translation_key="rig_name", - value_fn=lambda state: state.rig.name, - ), - KemperSensorEntityDescription( - key="amp_name", - translation_key="amp_name", - value_fn=lambda state: state.amp.name, - ), - KemperSensorEntityDescription( - key="cabinet_name", - translation_key="cabinet_name", - value_fn=lambda state: state.cabinet.name, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: KemperConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the Profiler's sensors.""" - coordinator = entry.runtime_data - entities: list[SensorEntity] = [ - KemperSensor(coordinator, description) for description in SENSORS - ] - entities.append(KemperLastActivitySensor(coordinator)) - async_add_entities(entities) - - -class KemperSensor(KemperEntity, SensorEntity): - """One value read straight out of the state tree.""" - - entity_description: KemperSensorEntityDescription - - def __init__( - self, coordinator: KemperCoordinator, description: KemperSensorEntityDescription - ) -> None: - super().__init__(coordinator, description.key) - self.entity_description = description - - @property - def native_value(self) -> str | None: - """The described value, or ``None`` while the device has not said.""" - state = self.coordinator.data - return None if state is None else self.entity_description.value_fn(state) - - -class KemperLastActivitySensor(KemperEntity, SensorEntity): - """When signal was last heard, written on the detector's transitions only.""" - - _attr_translation_key = "last_activity" - _attr_device_class = SensorDeviceClass.TIMESTAMP - - def __init__(self, coordinator: KemperCoordinator) -> None: - super().__init__(coordinator, "last_activity") - - async def async_added_to_hass(self) -> None: - """Follow the detector as well as the coordinator.""" - await super().async_added_to_hass() - self.async_on_remove(self.coordinator.activity.add_listener(self.async_write_ha_state)) - - @property - def native_value(self) -> datetime | None: - """The last crossing the detector settled on, or ``None`` before one.""" - return self.coordinator.activity.last_activity diff --git a/python/examples/homeassistant/custom_components/kemper/strings.json b/python/examples/homeassistant/custom_components/kemper/strings.json deleted file mode 100644 index 380af65..0000000 --- a/python/examples/homeassistant/custom_components/kemper/strings.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "config": { - "step": { - "pick": { - "title": "Choose a Profiler", - "description": "These Profilers answered on the local network.", - "data": { - "device": "Profiler" - } - }, - "manual": { - "title": "Connect to a Profiler", - "description": "Enter the address of the Profiler. Its network port is on the device under System > Network.", - "data": { - "host": "Host", - "port": "Port" - }, - "data_description": { - "host": "The Profiler's IP address or host name.", - "port": "Leave this at 5727 unless the Profiler was told to use another port." - } - } - }, - "error": { - "cannot_connect": "Could not open a session with that Profiler. Check the address, and that the Profiler is switched on and on the network.", - "unknown": "Unexpected error" - }, - "abort": { - "already_configured": "This Profiler is already configured" - } - }, - "options": { - "step": { - "init": { - "title": "Activity detection", - "description": "How the Profiler decides that someone is playing. The output level is read from the rig output meter, after the rig volume and before the master volume.", - "data": { - "activity_window": "Quiet window", - "activity_threshold": "Level threshold" - }, - "data_description": { - "activity_window": "How long the output has to stay below the threshold before activity turns off.", - "activity_threshold": "How loud the rig output has to be to count as playing, as a percentage of full scale." - } - } - } - }, - "entity": { - "binary_sensor": { - "active": { - "name": "Active" - } - }, - "sensor": { - "rig_name": { - "name": "Rig" - }, - "amp_name": { - "name": "Amp" - }, - "cabinet_name": { - "name": "Cabinet" - }, - "last_activity": { - "name": "Last activity" - } - } - } -} diff --git a/python/examples/homeassistant/custom_components/kemper/translations/en.json b/python/examples/homeassistant/custom_components/kemper/translations/en.json deleted file mode 100644 index 380af65..0000000 --- a/python/examples/homeassistant/custom_components/kemper/translations/en.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "config": { - "step": { - "pick": { - "title": "Choose a Profiler", - "description": "These Profilers answered on the local network.", - "data": { - "device": "Profiler" - } - }, - "manual": { - "title": "Connect to a Profiler", - "description": "Enter the address of the Profiler. Its network port is on the device under System > Network.", - "data": { - "host": "Host", - "port": "Port" - }, - "data_description": { - "host": "The Profiler's IP address or host name.", - "port": "Leave this at 5727 unless the Profiler was told to use another port." - } - } - }, - "error": { - "cannot_connect": "Could not open a session with that Profiler. Check the address, and that the Profiler is switched on and on the network.", - "unknown": "Unexpected error" - }, - "abort": { - "already_configured": "This Profiler is already configured" - } - }, - "options": { - "step": { - "init": { - "title": "Activity detection", - "description": "How the Profiler decides that someone is playing. The output level is read from the rig output meter, after the rig volume and before the master volume.", - "data": { - "activity_window": "Quiet window", - "activity_threshold": "Level threshold" - }, - "data_description": { - "activity_window": "How long the output has to stay below the threshold before activity turns off.", - "activity_threshold": "How loud the rig output has to be to count as playing, as a percentage of full scale." - } - } - } - }, - "entity": { - "binary_sensor": { - "active": { - "name": "Active" - } - }, - "sensor": { - "rig_name": { - "name": "Rig" - }, - "amp_name": { - "name": "Amp" - }, - "cabinet_name": { - "name": "Cabinet" - }, - "last_activity": { - "name": "Last activity" - } - } - } -} diff --git a/python/examples/homeassistant/pyproject.toml b/python/examples/homeassistant/pyproject.toml deleted file mode 100644 index 51216e9..0000000 --- a/python/examples/homeassistant/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[project] -# A development shell, not a distributable: the integration ships as the -# `custom_components/kemper` directory that build.py bundles, and Home -# Assistant loads it from a config directory rather than from a wheel. This -# file exists so `uv sync` can put Home Assistant's test harness on the path. -name = "kemper-homeassistant" -version = "0.1.0" -description = "Development environment for the Kemper Profiler Home Assistant integration." -requires-python = ">=3.14.2" -dependencies = [ - # Pins homeassistant==2026.8.3 and brings pytest + the custom-component fixtures. - "pytest-homeassistant-custom-component==0.13.357", - "ruff>=0.6", -] - -[tool.uv] -package = false - -[tool.pytest.ini_options] -testpaths = ["tests"] -# "." for `custom_components.kemper`, ../../src for the top-level `libkp` that -# libkp's own test harness imports, ../../tests for that harness (fake_device). -pythonpath = [".", "../../src", "../../tests"] -asyncio_mode = "auto" - -[tool.ruff] -line-length = 100 -# The vendored library is linted by python/'s own configuration; dist/ is build -# output, and the symlink would otherwise be linted twice under two names. -extend-exclude = ["custom_components/kemper/libkp", "dist"] - -[tool.ruff.lint] -select = ["E", "F", "I", "W", "UP"] diff --git a/python/examples/homeassistant/tests/conftest.py b/python/examples/homeassistant/tests/conftest.py deleted file mode 100644 index f383d09..0000000 --- a/python/examples/homeassistant/tests/conftest.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Fixtures: a Home Assistant that loads the integration, against a fake Profiler. - -The device side is libkp's own :class:`fake_device.FakeDevice` — the same -in-process stand-in its test suite drives — so these tests exercise the real -session handshake, the real MIDI3 framing and the real state fold, and mock -nothing below the config entry. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from unittest.mock import patch - -import pytest -from fake_device import FakeDevice, answer_requests -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.kemper.const import CONF_SERIAL, CONF_SW_VERSION, DOMAIN -from custom_components.kemper.libkp.errors import PortUnavailableError -from custom_components.kemper.libkp.protocol import PORT - -#: The serial the fixture entry claims, and so the prefix of every unique id. -SERIAL = "FAKE-SERIAL" -DEVICE_NAME = "Test Profiler" - - -@pytest.fixture(autouse=True) -def auto_enable_custom_integrations(enable_custom_integrations): - """Let Home Assistant see `custom_components/kemper` in every test.""" - return - - -@pytest.fixture(autouse=True) -def no_broadcast(): - """No test polls the real LAN. - - The discovery port reads as held by another program, which is a state the - integration is built to shrug off: every path that discovers falls back to - what the entry already knows. A test that wants replies patches - ``custom_components.kemper.discovery.async_discover`` instead. - """ - with patch( - "custom_components.kemper.discovery.DiscoveryPort.acquire", - side_effect=PortUnavailableError(PORT, OSError("held by the test suite")), - ): - yield - - -@pytest.fixture -async def device(socket_enabled: None) -> AsyncIterator[FakeDevice]: - """A Profiler stand-in that answers the model's opening burst. - - ``socket_enabled`` lifts Home Assistant's test-suite ban on real sockets: - these tests deliberately want one, since a loopback TCP session is exactly - what the integration does in the field. - """ - fake = await FakeDevice(responder=answer_requests).start() - try: - yield fake - finally: - # Hang up first: a server whose handlers are still running never - # finishes closing, and an entry that failed to unload leaves one. - await fake.hangup() - await fake.stop() - - -def make_entry(device: FakeDevice) -> MockConfigEntry: - """A config entry pointing at the fake device's ephemeral port.""" - return MockConfigEntry( - domain=DOMAIN, - title=DEVICE_NAME, - unique_id=SERIAL, - data={ - CONF_HOST: "127.0.0.1", - CONF_PORT: device.port, - CONF_NAME: DEVICE_NAME, - CONF_SERIAL: SERIAL, - CONF_SW_VERSION: "1.2.3", - }, - ) - - -@pytest.fixture -async def entry(hass: HomeAssistant, device: FakeDevice) -> AsyncIterator[MockConfigEntry]: - """A loaded config entry, unloaded again with the test. - - Unloading matters here: it is what closes the session and disarms the - detector's timer, and Home Assistant's test harness fails a test that - leaves either behind. - """ - entry = make_entry(device) - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - yield entry - if entry.state is ConfigEntryState.LOADED: - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - -def entity_id(hass: HomeAssistant, platform: str, key: str) -> str: - """The entity id the registry gave one of the integration's unique ids.""" - found = er.async_get(hass).async_get_entity_id(platform, DOMAIN, f"{SERIAL}_{key}") - assert found is not None, f"no {platform} entity registered for {key}" - return found - - -async def wait_until(predicate, timeout: float = 5.0) -> None: - """Wait until ``predicate`` is true; the wire is asynchronous.""" - async with asyncio.timeout(timeout): - while not predicate(): - await asyncio.sleep(0.01) - - -async def wait_for_state( - hass: HomeAssistant, entity: str, value: str, timeout: float = 5.0 -) -> None: - """Wait until ``entity`` reads ``value``; the wire is asynchronous.""" - async with asyncio.timeout(timeout): - while True: - state = hass.states.get(entity) - if state is not None and state.state == value: - return - await asyncio.sleep(0.01) diff --git a/python/examples/homeassistant/tests/test_binary_sensor.py b/python/examples/homeassistant/tests/test_binary_sensor.py deleted file mode 100644 index 2d6dd9c..0000000 --- a/python/examples/homeassistant/tests/test_binary_sensor.py +++ /dev/null @@ -1,161 +0,0 @@ -"""The activity detector, at the rate the device really pushes meters. - -Every assertion here is about *how many* state writes come out: the meter lane -runs at ~20 Hz, and the whole point of the detector is that Home Assistant -sees two states per playing session and not two per second. -""" - -from __future__ import annotations - -from datetime import timedelta - -from conftest import entity_id, wait_for_state -from fake_device import FakeDevice -from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.util import dt as dt_util -from libkp import _generated as gen -from libkp.nrpn import sysex, u14_split -from pytest_homeassistant_custom_component.common import ( - MockConfigEntry, - async_fire_time_changed, -) - -from custom_components.kemper.const import DEFAULT_ACTIVITY_WINDOW - -#: Rig output level (v6) well above and well below the 2% default threshold. -LOUD = 9000 -QUIET = 100 - - -def meter_message(rig_out_level: int) -> bytes: - """One realtime status frame carrying ``rig_out_level`` in v6.""" - values = [0] * gen.METER_COUNT - values[6] = rig_out_level - payload = bytearray() - for value in values: - payload.extend(u14_split(value)) - return sysex(0x00, 0x00, 0x02, gen.PAGE_REALTIME, gen.METER_BLOCK_NUMBER, bytes(payload)) - - -def count_changes(hass: HomeAssistant, entity: str) -> list[Event]: - """Collect every state change of one entity from now on.""" - seen: list[Event] = [] - - @callback - def record(event: Event) -> None: - if event.data["entity_id"] == entity: - seen.append(event) - - hass.bus.async_listen(EVENT_STATE_CHANGED, record) - return seen - - -async def test_quiet_frames_do_not_count_as_playing( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """A stream with nothing plugged into it still pushes meters.""" - active = entity_id(hass, "binary_sensor", "active") - changes = count_changes(hass, active) - - for _ in range(20): - await device.push(meter_message(QUIET)) - await hass.async_block_till_done() - - assert hass.states.get(active).state == STATE_OFF - assert changes == [] - assert hass.states.get(entity_id(hass, "sensor", "last_activity")).state == "unknown" - - -async def test_a_burst_of_frames_is_one_state_write( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """A hundred frames — five seconds of playing — write the state once.""" - active = entity_id(hass, "binary_sensor", "active") - last_activity = entity_id(hass, "sensor", "last_activity") - changes = count_changes(hass, active) - activity_changes = count_changes(hass, last_activity) - - for _ in range(100): - await device.push(meter_message(LOUD)) - await wait_for_state(hass, active, STATE_ON) - await hass.async_block_till_done() - - assert len(changes) == 1 - assert len(activity_changes) == 1 - assert hass.states.get(last_activity).state != "unknown" - - -async def test_the_window_settles_the_sensor_off( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Silence for the whole window turns it off — and that is the second write.""" - active = entity_id(hass, "binary_sensor", "active") - await device.push(meter_message(LOUD)) - await wait_for_state(hass, active, STATE_ON) - - changes = count_changes(hass, active) - window = timedelta(minutes=DEFAULT_ACTIVITY_WINDOW) - async_fire_time_changed(hass, dt_util.utcnow() + window + timedelta(seconds=1)) - await hass.async_block_till_done() - - assert hass.states.get(active).state == STATE_OFF - assert len(changes) == 1 - - -async def test_playing_again_before_the_window_ends_keeps_it_on( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """A pause shorter than the window re-arms the timer instead of settling.""" - active = entity_id(hass, "binary_sensor", "active") - await device.push(meter_message(LOUD)) - await wait_for_state(hass, active, STATE_ON) - - changes = count_changes(hass, active) - # The timer fires early — the model has heard a note since it was armed. - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) - await hass.async_block_till_done() - - assert hass.states.get(active).state == STATE_ON - assert changes == [] - - -async def test_a_shorter_window_applies_without_a_reconnect( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Saving the options form retunes the running detector.""" - from custom_components.kemper.const import CONF_ACTIVITY_THRESHOLD, CONF_ACTIVITY_WINDOW - - active = entity_id(hass, "binary_sensor", "active") - await device.push(meter_message(LOUD)) - await wait_for_state(hass, active, STATE_ON) - - hass.config_entries.async_update_entry( - entry, options={CONF_ACTIVITY_WINDOW: 1, CONF_ACTIVITY_THRESHOLD: 2} - ) - await hass.async_block_till_done() - - detector = entry.runtime_data.activity - assert detector.window == 60.0 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61)) - await hass.async_block_till_done() - assert hass.states.get(active).state == STATE_OFF - - -async def test_a_louder_threshold_ignores_quiet_playing( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """The threshold is a percentage of full scale, and it is enforced.""" - from custom_components.kemper.const import CONF_ACTIVITY_THRESHOLD, CONF_ACTIVITY_WINDOW - - hass.config_entries.async_update_entry( - entry, options={CONF_ACTIVITY_WINDOW: 5, CONF_ACTIVITY_THRESHOLD: 90} - ) - await hass.async_block_till_done() - - active = entity_id(hass, "binary_sensor", "active") - for _ in range(20): - await device.push(meter_message(LOUD)) - await hass.async_block_till_done() - - assert hass.states.get(active).state == STATE_OFF diff --git a/python/examples/homeassistant/tests/test_build.py b/python/examples/homeassistant/tests/test_build.py deleted file mode 100644 index f8c2450..0000000 --- a/python/examples/homeassistant/tests/test_build.py +++ /dev/null @@ -1,75 +0,0 @@ -"""The bundler: what leaves the source tree, and in what shape. - -The one thing that has to be true of a bundle is that the ``libkp`` symlink -became a real directory: Home Assistant copies a custom component into its -configuration and would find nothing at the other end of a relative symlink. -""" - -from __future__ import annotations - -import zipfile -from pathlib import Path - -import pytest - -import build - - -@pytest.fixture(autouse=True) -def dist_in_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Build into the test's own directory, never the working tree's dist/.""" - dist = tmp_path / "dist" - monkeypatch.setattr(build, "DIST", dist) - return dist - - -def test_the_library_is_vendored_as_real_files(tmp_path: Path) -> None: - """No symlink survives the copy, and the generated module comes with it.""" - build.build() - staged = tmp_path / "dist" / "custom_components" / "kemper" - - assert (staged / "manifest.json").is_file() - library = staged / "libkp" - assert library.is_dir() - assert not library.is_symlink() - generated = library / "_generated.py" - assert generated.is_file() - assert not generated.is_symlink() - assert "SPEC_VERSION" in generated.read_text(encoding="utf-8") - - -def test_nothing_that_should_not_ship_ships(tmp_path: Path) -> None: - """Bytecode caches and the integration's tests stay behind.""" - build.build() - staged = tmp_path / "dist" / "custom_components" / "kemper" - - assert not list(staged.rglob("__pycache__")) - assert not list(staged.rglob("*.pyc")) - assert not list(staged.rglob("tests")) - - -def test_the_zip_unpacks_into_a_config_directory(tmp_path: Path) -> None: - """Its paths are relative to the configuration directory, ready to unzip.""" - archive = build.build() - assert archive.name == f"kemper-{build.version()}.zip" - - with zipfile.ZipFile(archive) as zf: - names = zf.namelist() - assert "custom_components/kemper/manifest.json" in names - assert "custom_components/kemper/libkp/_generated.py" in names - assert "custom_components/kemper/translations/en.json" in names - - -def test_install_replaces_an_existing_copy(tmp_path: Path) -> None: - """Installing twice leaves one copy, not a merge of two.""" - build.build() - config_dir = tmp_path / "config" - config_dir.mkdir() - - target = build.install(config_dir) - stale = target / "stale.py" - stale.write_text("# left over from an older version\n", encoding="utf-8") - - build.install(config_dir) - assert (target / "manifest.json").is_file() - assert not stale.exists() diff --git a/python/examples/homeassistant/tests/test_config_flow.py b/python/examples/homeassistant/tests/test_config_flow.py deleted file mode 100644 index b9ba946..0000000 --- a/python/examples/homeassistant/tests/test_config_flow.py +++ /dev/null @@ -1,201 +0,0 @@ -"""The config flow: discovery, the manual fallback, and the options form.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest -from fake_device import FakeDevice -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.kemper.config_flow import MANUAL -from custom_components.kemper.const import ( - CONF_ACTIVITY_THRESHOLD, - CONF_ACTIVITY_WINDOW, - CONF_SERIAL, - CONF_SW_VERSION, - DOMAIN, -) -from custom_components.kemper.discovery import Found - -FOUND = Found(host="10.0.0.5", name="Studio Profiler", serial="SER123", version="10.5.2") - - -@pytest.fixture -def no_setup(): - """Stop a created entry from dialing a device the test does not have.""" - with patch( - "custom_components.kemper.async_setup_entry", AsyncMock(return_value=True) - ) as mocked: - yield mocked - - -async def start(hass: HomeAssistant, found: list[Found]) -> dict: - """Run the user step with a canned discovery result.""" - with patch("custom_components.kemper.config_flow.async_discover", return_value=found): - return await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) - - -async def test_a_discovered_profiler_needs_no_session( - hass: HomeAssistant, no_setup: AsyncMock -) -> None: - """Discovery already carries the name, serial and version — take them.""" - result = await start(hass, [FOUND]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pick" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_DEVICE: FOUND.host} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Studio Profiler" - assert result["data"][CONF_HOST] == "10.0.0.5" - assert result["result"].unique_id == "SER123" - - -async def test_a_profiler_is_only_added_once(hass: HomeAssistant, no_setup: AsyncMock) -> None: - """The serial is the identity, so the same device cannot be added twice.""" - existing = MockConfigEntry(domain=DOMAIN, unique_id="SER123", data={CONF_HOST: "10.0.0.9"}) - existing.add_to_hass(hass) - - result = await start(hass, [FOUND]) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_DEVICE: FOUND.host} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - # The address is refreshed on the way out: devices move between leases. - assert existing.data[CONF_HOST] == "10.0.0.5" - - -async def test_no_replies_falls_through_to_the_form(hass: HomeAssistant) -> None: - """A held discovery port or a quiet network is not an error.""" - result = await start(hass, []) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" - - -async def test_the_manual_form_is_reachable_from_the_list(hass: HomeAssistant) -> None: - """A Profiler on another subnet never answers the broadcast.""" - result = await start(hass, [FOUND]) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_DEVICE: MANUAL} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual" - - -async def test_a_manual_host_is_checked_with_one_session( - hass: HomeAssistant, device: FakeDevice, no_setup: AsyncMock -) -> None: - """The check is a real handshake against the fake device, opened once.""" - result = await start(hass, []) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "127.0.0.1", CONF_PORT: device.port} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_PORT] == device.port - assert result["result"].unique_id == "127.0.0.1" - assert len(device.connections) == 1 - - -async def test_a_host_that_does_not_answer_says_so(hass: HomeAssistant) -> None: - """A refused connection is a form error, not a traceback.""" - result = await start(hass, []) - with patch("custom_components.kemper.config_flow.async_check", side_effect=OSError("refused")): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "10.0.0.7", CONF_PORT: 5727} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_an_unexpected_failure_says_unknown(hass: HomeAssistant) -> None: - """Anything the stack can raise leaves the form usable.""" - result = await start(hass, []) - with patch("custom_components.kemper.config_flow.async_check", side_effect=ValueError("odd")): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "10.0.0.7", CONF_PORT: 5727} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - - -async def test_the_options_form_retunes_the_detector( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Saving options reaches the running detector, and only it.""" - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], {CONF_ACTIVITY_WINDOW: 10, CONF_ACTIVITY_THRESHOLD: 5} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert entry.options[CONF_ACTIVITY_WINDOW] == 10 - detector = entry.runtime_data.activity - assert detector.window == 600.0 - assert detector.threshold == 819 - - -async def test_a_manual_host_adopts_its_serial( - hass: HomeAssistant, device: FakeDevice, no_setup: AsyncMock -) -> None: - """A hand-typed address is keyed by the serial the device answers with.""" - identified = Found(host="127.0.0.1", name="Studio Profiler", serial="SER123", version="10.5.3") - result = await start(hass, []) - with patch("custom_components.kemper.discovery.async_discover", return_value=[identified]): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "127.0.0.1", CONF_PORT: device.port} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Studio Profiler" - assert result["result"].unique_id == "SER123" - assert result["data"][CONF_SERIAL] == "SER123" - assert result["data"][CONF_SW_VERSION] == "10.5.3" - - -async def test_a_silent_device_is_still_keyed_by_its_host( - hass: HomeAssistant, device: FakeDevice, no_setup: AsyncMock -) -> None: - """With the discovery port held, the host is all the identity there is.""" - result = await start(hass, []) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "127.0.0.1", CONF_PORT: device.port} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == "127.0.0.1" - assert result["data"][CONF_SERIAL] is None - - -async def test_re_adding_a_moved_device_by_hand_updates_the_entry( - hass: HomeAssistant, device: FakeDevice, no_setup: AsyncMock -) -> None: - """Typing in the new address of a known Profiler moves it, not clones it.""" - existing = MockConfigEntry( - domain=DOMAIN, unique_id="SER123", data={CONF_HOST: "10.0.0.9", CONF_PORT: 5727} - ) - existing.add_to_hass(hass) - identified = Found(host="127.0.0.1", name="Studio Profiler", serial="SER123", version="10.5.3") - - result = await start(hass, []) - with patch("custom_components.kemper.discovery.async_discover", return_value=[identified]): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "127.0.0.1", CONF_PORT: device.port} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert existing.data[CONF_HOST] == "127.0.0.1" - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/python/examples/homeassistant/tests/test_diagnostics.py b/python/examples/homeassistant/tests/test_diagnostics.py deleted file mode 100644 index 46b7d00..0000000 --- a/python/examples/homeassistant/tests/test_diagnostics.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The diagnostics download: the whole tree, minus the address of the device.""" - -from __future__ import annotations - -from conftest import entity_id, wait_for_state -from fake_device import FakeDevice -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from libkp import _generated as gen -from libkp.nrpn import PAGE_STRINGS, sysex -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.kemper.diagnostics import async_get_config_entry_diagnostics - - -async def test_the_dump_is_json_and_redacted( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Enums come out as their values and the host does not come out at all.""" - await device.push( - sysex(0x00, 0x00, 0x03, PAGE_STRINGS, gen.STRING_RIG_NAME, b"Crunchy Vox\x00") - ) - await wait_for_state(hass, entity_id(hass, "sensor", "rig_name"), "Crunchy Vox") - - diagnostics = await async_get_config_entry_diagnostics(hass, entry) - - assert diagnostics["entry"]["data"][CONF_HOST] == "**REDACTED**" - assert diagnostics["state"]["connection"] == "connected" - assert diagnostics["state"]["rig"]["name"] == "Crunchy Vox" - # The parts that never became entities are here, which is the point. - assert len(diagnostics["state"]["effects"]) == 8 - assert len(diagnostics["state"]["status"]["raw"]) == gen.METER_COUNT - assert diagnostics["activity"]["active"] is False diff --git a/python/examples/homeassistant/tests/test_init.py b/python/examples/homeassistant/tests/test_init.py deleted file mode 100644 index dffc69b..0000000 --- a/python/examples/homeassistant/tests/test_init.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Setting the entry up, tearing it down, and what that costs the device.""" - -from __future__ import annotations - -import asyncio -from datetime import timedelta -from unittest.mock import patch - -from conftest import DEVICE_NAME, SERIAL, entity_id, make_entry, wait_until -from fake_device import FakeDevice -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util -from pytest_homeassistant_custom_component.common import ( - MockConfigEntry, - async_fire_time_changed, -) - -from custom_components.kemper.const import ( - CONF_ACTIVITY_THRESHOLD, - CONF_ACTIVITY_WINDOW, - CONF_SERIAL, - CONF_SW_VERSION, - DOMAIN, -) -from custom_components.kemper.coordinator import RELOAD_DELAY_SECONDS -from custom_components.kemper.discovery import Found -from custom_components.kemper.libkp.session import PROTOCOL_CBOR_CONTROL, PROTOCOL_MIDI3_STREAM - - -async def test_setup_opens_exactly_one_session( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """One stream, and no control channel: the entities need nothing from it.""" - assert entry.state is ConfigEntryState.LOADED - assert device.connection_count(PROTOCOL_MIDI3_STREAM) == 1 - assert device.connection_count(PROTOCOL_CBOR_CONTROL) == 0 - - -async def test_setup_registers_the_device(hass: HomeAssistant, entry: MockConfigEntry) -> None: - """Discovery's name and version reach the device registry.""" - device_entry = dr.async_get(hass).async_get_device(identifiers={(DOMAIN, SERIAL)}) - assert device_entry is not None - assert device_entry.name == DEVICE_NAME - assert device_entry.manufacturer == "Kemper" - assert device_entry.model == "Profiler" - assert device_entry.sw_version == "1.2.3" - - -async def test_unload_hangs_up( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Unloading closes the socket; the fake sees the hangup.""" - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.NOT_LOADED - connection = device.connections[0] - async with asyncio.timeout(5): - await connection.closed.wait() - - -async def test_a_device_that_is_not_there_retries_later( - hass: HomeAssistant, device: FakeDevice -) -> None: - """A refused connection is ``ConfigEntryNotReady``, not a hard failure.""" - entry = make_entry(device) - await device.stop() # nothing is listening on that port any more - entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_changing_options_does_not_reconnect( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """The detector is retuned in place: same model, same socket, new numbers.""" - coordinator = entry.runtime_data - model = coordinator.model - - hass.config_entries.async_update_entry( - entry, options={CONF_ACTIVITY_WINDOW: 1, CONF_ACTIVITY_THRESHOLD: 50} - ) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.LOADED - assert entry.runtime_data.model is model - assert device.connection_count(PROTOCOL_MIDI3_STREAM) == 1 - detector = coordinator.activity - assert detector.window == 60.0 - assert detector.threshold == 8192 - - -async def test_the_configured_port_is_the_one_dialed( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """The entry's port, not libkp's default, decides where the model dials.""" - assert entry.data[CONF_PORT] == device.port - assert device.connections - - -async def test_setup_follows_the_serial_to_a_new_address( - hass: HomeAssistant, device: FakeDevice -) -> None: - """The lease moved: the entry knows the serial, so it finds the device again.""" - entry = MockConfigEntry( - domain=DOMAIN, - title=DEVICE_NAME, - unique_id=SERIAL, - data={ - CONF_HOST: "10.0.0.99", # where it used to be - CONF_PORT: device.port, - CONF_NAME: "Old Name", - CONF_SERIAL: SERIAL, - CONF_SW_VERSION: "1.0.0", - }, - ) - entry.add_to_hass(hass) - moved = Found(host="127.0.0.1", name="Studio Profiler", serial=SERIAL, version="10.5.3") - - with patch("custom_components.kemper.discovery.async_discover", return_value=[moved]): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - try: - assert entry.state is ConfigEntryState.LOADED - # The entry followed the device, name and firmware version included. - assert entry.data[CONF_HOST] == "127.0.0.1" - assert entry.data[CONF_NAME] == "Studio Profiler" - assert entry.data[CONF_SW_VERSION] == "10.5.3" - # And it is the same device, with the same entities. - assert hass.states.get(entity_id(hass, "sensor", "rig_name")).state != "unavailable" - device_entry = dr.async_get(hass).async_get_device(identifiers={(DOMAIN, SERIAL)}) - assert device_entry is not None - assert device_entry.name == "Studio Profiler" - assert device_entry.sw_version == "10.5.3" - finally: - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - -async def test_setup_uses_the_stored_host_when_nothing_answers( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """A held discovery port must not stop a Profiler that has not moved.""" - assert entry.state is ConfigEntryState.LOADED - assert entry.data[CONF_HOST] == "127.0.0.1" - assert device.connection_count(PROTOCOL_MIDI3_STREAM) == 1 - - -async def test_a_lost_stream_reloads_the_entry( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Coming back goes through setup, so it starts by finding the device again.""" - coordinator = entry.runtime_data - await device.hangup() - await wait_until(lambda: coordinator.reload_pending) - - # The session was seconds old, so the reload waits rather than spinning. - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=RELOAD_DELAY_SECONDS + 1)) - await hass.async_block_till_done() - await wait_until(lambda: entry.state is ConfigEntryState.LOADED) - - assert entry.runtime_data is not coordinator - assert device.connection_count(PROTOCOL_MIDI3_STREAM) == 2 - assert hass.states.get(entity_id(hass, "sensor", "rig_name")).state != "unavailable" - - -async def test_unloading_a_lost_entry_cancels_the_reload( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Nothing dials after the entry is gone, and nothing is left armed.""" - coordinator = entry.runtime_data - await device.hangup() - await wait_until(lambda: coordinator.reload_pending) - - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - assert not coordinator.reload_pending - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=RELOAD_DELAY_SECONDS + 1)) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED - assert device.connection_count(PROTOCOL_MIDI3_STREAM) == 1 - - -async def test_every_entity_is_keyed_by_the_serial( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Entity identity survives a move, because it never mentions the address.""" - registry = er.async_get(hass) - entities = er.async_entries_for_config_entry(registry, entry.entry_id) - unique_ids = {item.unique_id for item in entities} - assert unique_ids == { - f"{SERIAL}_rig_name", - f"{SERIAL}_amp_name", - f"{SERIAL}_cabinet_name", - f"{SERIAL}_last_activity", - f"{SERIAL}_active", - } diff --git a/python/examples/homeassistant/tests/test_sensor.py b/python/examples/homeassistant/tests/test_sensor.py deleted file mode 100644 index 9924e84..0000000 --- a/python/examples/homeassistant/tests/test_sensor.py +++ /dev/null @@ -1,62 +0,0 @@ -"""The name sensors, driven by what the device actually pushes. - -The messages come from libkp's own message builders, so what these tests put -on the wire is byte-for-byte what a Profiler puts there when a rig is loaded. -""" - -from __future__ import annotations - -from conftest import entity_id, wait_for_state -from fake_device import FakeDevice -from homeassistant.core import HomeAssistant -from libkp import _generated as gen -from libkp.nrpn import PAGE_STRINGS, sysex -from pytest_homeassistant_custom_component.common import MockConfigEntry - -#: The page-0 string tags a rig change pushes, by their spec names. -RIG_NAME = gen.STRING_RIG_NAME -AMP_NAME = gen.STRING_AMP_NAME -CABINET_NAME = gen.STRING_CABINET_NAME - - -def string_tag(number: int, text: str) -> bytes: - """A ``$03`` String Parameter push, as a rig change sends.""" - return sysex(0x00, 0x00, 0x03, PAGE_STRINGS, number, text.encode("ascii") + b"\x00") - - -async def test_the_names_follow_the_device( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Load a rig on the device and the three sensors say what it is.""" - await device.push(string_tag(RIG_NAME, "Crunchy Vox")) - await device.push(string_tag(AMP_NAME, "Vintage Twin")) - await device.push(string_tag(CABINET_NAME, "2x12 Alnico")) - - await wait_for_state(hass, entity_id(hass, "sensor", "rig_name"), "Crunchy Vox") - await wait_for_state(hass, entity_id(hass, "sensor", "amp_name"), "Vintage Twin") - await wait_for_state(hass, entity_id(hass, "sensor", "cabinet_name"), "2x12 Alnico") - - -async def test_a_second_rig_replaces_the_first( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Nothing is cached across rigs: the last push wins.""" - await device.push(string_tag(RIG_NAME, "Crunchy Vox")) - await wait_for_state(hass, entity_id(hass, "sensor", "rig_name"), "Crunchy Vox") - - await device.push(string_tag(RIG_NAME, "Clean Twin")) - await wait_for_state(hass, entity_id(hass, "sensor", "rig_name"), "Clean Twin") - - -async def test_losing_the_stream_makes_the_sensors_unavailable( - hass: HomeAssistant, device: FakeDevice, entry: MockConfigEntry -) -> None: - """Availability follows the connection, not the last value seen.""" - await device.push(string_tag(RIG_NAME, "Crunchy Vox")) - rig = entity_id(hass, "sensor", "rig_name") - await wait_for_state(hass, rig, "Crunchy Vox") - - await device.hangup() - - await wait_for_state(hass, rig, "unavailable") - await wait_for_state(hass, entity_id(hass, "sensor", "last_activity"), "unavailable") diff --git a/python/src/libkp/__init__.py b/python/src/libkp/__init__.py index 2143a47..48199c7 100644 --- a/python/src/libkp/__init__.py +++ b/python/src/libkp/__init__.py @@ -16,6 +16,9 @@ - :mod:`libkp.model` — :class:`~libkp.model.DeviceModel`, the async store over the stream and the control link. +Beside the layers, :mod:`libkp.testing` holds :class:`~libkp.testing.FakeDevice`, +an in-process Profiler for driving all of the above without a device. + Constants and lookup tables come from :mod:`libkp._generated`, which is emitted from the shared spec; the protocol logic is hand-written here and held to the shared conformance vectors. diff --git a/python/tests/fake_device.py b/python/src/libkp/testing.py similarity index 97% rename from python/tests/fake_device.py rename to python/src/libkp/testing.py index d047393..dad3f3d 100644 --- a/python/tests/fake_device.py +++ b/python/src/libkp/testing.py @@ -1,4 +1,9 @@ -"""An in-process stand-in for a Profiler, for exercising the async layers. +"""An in-process stand-in for a Profiler, for tests that want a real session. + +Shipped with the package so that anything built on libkp -- a Home Assistant +integration, a controller, a script -- can drive the real transport in its own +test suite without a device on the desk and without mocking the layers under +test. libkp's own async tests use it as it stands. It speaks just enough of the transport to drive :class:`libkp.session.Session`, :class:`libkp.model.DeviceModel` and the CBOR tooling: any number of concurrent @@ -34,9 +39,9 @@ import asyncio from collections.abc import Callable, Iterable -from libkp import _generated as gen -from libkp import cbor, midi3, nrpn -from libkp.session import ( +from . import _generated as gen +from . import cbor, midi3, nrpn +from .session import ( PROTOCOL_CBOR_CONTROL, PROTOCOL_MIDI3_STREAM, PROTOCOL_RESERVED, diff --git a/python/tests/test_cbor.py b/python/tests/test_cbor.py index ca37e48..768d779 100644 --- a/python/tests/test_cbor.py +++ b/python/tests/test_cbor.py @@ -5,12 +5,11 @@ import asyncio -from fake_device import DEFAULT_DUMP, FakeDevice, wait_for - from libkp import _generated as gen from libkp import cbor from libkp.session import PROTOCOL_CBOR_CONTROL from libkp.state import Num, Text +from libkp.testing import DEFAULT_DUMP, FakeDevice, wait_for def test_encodes_with_minimal_length_heads(): diff --git a/python/tests/test_meters_example.py b/python/tests/test_meters_example.py index b415309..7f63a48 100644 --- a/python/tests/test_meters_example.py +++ b/python/tests/test_meters_example.py @@ -10,11 +10,11 @@ import meters import pytest -from fake_device import FakeDevice from libkp import _generated as gen from libkp.nrpn import PAGE_STRINGS, set_single, sysex, u14_split from libkp.state import BeatPulse, DeviceState, ParamChanged, RealtimeStatus, Status +from libkp.testing import FakeDevice ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]") diff --git a/python/tests/test_model.py b/python/tests/test_model.py index 7f6f362..a4ec405 100644 --- a/python/tests/test_model.py +++ b/python/tests/test_model.py @@ -12,7 +12,6 @@ import asyncio import pytest -from fake_device import FakeDevice, answer_requests, ext_param, wait_for from libkp import _generated as gen from libkp import cbor @@ -57,6 +56,7 @@ SyncCompleted, TempoBpm, ) +from libkp.testing import FakeDevice, answer_requests, ext_param, wait_for RIG_NAME = sysex(0x00, 0x00, 0x03, PAGE_STRINGS, 1, b"Test Rig\x00") REV_TYPE = set_single(0x00, 0x00, 0x3D, 0, 179) diff --git a/python/tests/test_session.py b/python/tests/test_session.py index ddd613d..9d729ba 100644 --- a/python/tests/test_session.py +++ b/python/tests/test_session.py @@ -12,7 +12,6 @@ import asyncio import pytest -from fake_device import FakeDevice from libkp.errors import ConnectError, ProtocolRejectedError, TimeoutErrorLibKP from libkp.session import ( @@ -24,6 +23,7 @@ Session, parse_protocol_list, ) +from libkp.testing import FakeDevice IDLE = 0.2