Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ of the current rig, its effect blocks, the tuner strobe, and the output meters:

```sh
cd rust && cargo run --example meters # add `-- --ip 192.168.1.50` to skip discovery
cd python && python -m libkp.examples.meters # or `pip install -e .` then `libkp-meters`
cd python && uv run examples/meters.py # or `pip install -e .` then `python examples/meters.py`
cd swift && swift run meters # or `swift run MetersApp` for the macOS app
```

Expand Down
2 changes: 1 addition & 1 deletion docs/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Even with no requests outstanding, an open MIDI3 stream delivers:
comment, amp, cabinet, every effect-slot type and state, and the rig settings.

The `meters` example in each language (`rust/examples`,
`python/src/libkp/examples`, `swift/Sources/meters`) does exactly this and
`python/examples`, `swift/Sources/meters`) does exactly this and
nothing more: connect, subscribe, and render what arrives.

## The model
Expand Down
2 changes: 1 addition & 1 deletion docs/07-realtime-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ The note being tracked is reported separately at page `$7D`, number 84
## The `meters` example

Each implementation ships the same example — `rust/examples`,
`python/src/libkp/examples`, `swift/Sources/meters` — and it exists to make this
`python/examples`, `swift/Sources/meters` — and it exists to make this
document concrete. It opens a session, ingests the stream, and renders a live
terminal view:

Expand Down
37 changes: 14 additions & 23 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,14 @@ pip install -e '.[dev]' # from python/
Or run straight from the source tree:

```sh
PYTHONPATH=src python -m libkp.examples.meters --help
PYTHONPATH=src python examples/meters.py --help
```

## The live meters example

```sh
python -m libkp.examples.meters # discover a device, then render
python -m libkp.examples.meters --ip 192.168.1.50 --all --width 48
libkp-meters --help # same thing, installed as a script
python examples/meters.py # discover a device, then render
python examples/meters.py --ip 192.168.1.50 --all --width 48
```

A full-screen ANSI view that updates straight off the stream:
Expand Down Expand Up @@ -63,8 +62,8 @@ Ctrl-C restores the cursor and exits.
gauges, an effect-block grid, a live tuner strobe) built on
[Textual](https://textual.textualize.io/). It needs the optional `tui` extra
(Textual); the library itself stays dependency-free. Run it with `uv` (below),
or `pip install -e '.[tui]'` then `libkp-meters-tui`. Same core flags as the
ANSI example; press `q` to quit and `a` to toggle the raw fields.
or `pip install -e '.[tui]'` then `python examples/meters_tui.py`. Same core
flags as the ANSI example; press `q` to quit and `a` to toggle the raw fields.

## Running the examples with uv

Expand All @@ -73,33 +72,25 @@ virtualenv — it resolves the package (and any extras) on the fly. From `python

```sh
# Zero-dependency ANSI meters
uv run libkp-meters --help
uv run libkp-meters --ip 10.0.0.1 --all
uv run examples/meters.py --help
uv run examples/meters.py --ip 10.0.0.1 --all

# Textual TUI — the `--extra tui` pulls in Textual just for this run
uv run --extra tui libkp-meters-tui
uv run --extra tui libkp-meters-tui --ip 10.0.0.1
```

The console scripts above come from `pyproject.toml`; the equivalent module form
works too:

```sh
uv run python -m libkp.examples.meters
uv run --extra tui python -m libkp.examples.meters_tui
uv run --extra tui examples/meters_tui.py
uv run --extra tui examples/meters_tui.py --ip 10.0.0.1
```

Pin the interpreter (anything 3.11+) when you want a specific one, and
materialize the environment once for repeated runs or editor tooling:

```sh
uv run --python 3.14 --extra tui libkp-meters-tui # choose the interpreter
uv sync --extra tui # create .venv with Textual
uv run libkp-meters-tui # then reuse it
uv run --python 3.14 --extra tui examples/meters_tui.py # choose the interpreter
uv sync --extra tui # create .venv with Textual
uv run examples/meters_tui.py # then reuse it
```

Only the Textual example needs the `tui` extra; `uv run libkp-meters` needs
nothing beyond the standard library.
Only the Textual example needs the `tui` extra; `uv run examples/meters.py`
needs nothing beyond the standard library.

## Quick start

Expand Down
39 changes: 39 additions & 0 deletions python/examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# libkp Python examples

Runnable front-ends over the `libkp` library, kept beside it rather than inside
it (mirroring `../../rust/examples` and Swift's separate executable targets) so
neither ships in the installed package.

- **`meters.py`** — a zero-dependency, full-screen ANSI view of the current
rig, amp/cab, the eight effect blocks, the tuner strobe, and the realtime
level meters.
- **`meters_tui.py`** — the same view as a richer [Textual](https://textual.textualize.io/)
TUI (rounded panels, colored gauges). Needs the optional `tui` extra.
- **`homeassistant/`** — a Home Assistant custom integration: the loaded rig,
amp and cabinet as sensors, plus an `active` binary sensor that turns the
~20 Hz meter lane into two state writes per playing session. Its own uv
project, with its own README, tests and bundler.

## Running them

From `python/`, with [uv](https://docs.astral.sh/uv/) (no manual virtualenv
needed — it resolves `libkp` and any extras on the fly):

```sh
uv run examples/meters.py --help
uv run examples/meters.py --ip 192.168.1.50 --all --width 48

uv run --extra tui examples/meters_tui.py --help
uv run --extra tui examples/meters_tui.py --ip 192.168.1.50
```

Or, after `pip install -e .` (add `'.[tui]'` for the Textual example):

```sh
python examples/meters.py --help
python examples/meters_tui.py --help
```

Both discover a device over UDP broadcast when `--ip` is omitted; quit
Kemper's Rig Manager first if it holds the discovery port. Ctrl-C quits either
one and restores the terminal.
119 changes: 119 additions & 0 deletions python/examples/homeassistant/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 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.<device>_rig Rig name "Crunchy Vox"
├─ sensor.<device>_amp Amp name "Vintage Twin"
├─ sensor.<device>_cabinet Cabinet name "2x12 Alnico"
├─ binary_sensor.<device>_active Playing? on / off
└─ sensor.<device>_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-<version>.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.
96 changes: 96 additions & 0 deletions python/examples/homeassistant/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/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 ``<config>/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())
Loading
Loading