libkp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- libkp-0.1.0/.gitignore +27 -0
- libkp-0.1.0/PKG-INFO +440 -0
- libkp-0.1.0/README.md +425 -0
- libkp-0.1.0/examples/README.md +39 -0
- libkp-0.1.0/examples/meters.py +479 -0
- libkp-0.1.0/examples/meters_tui.py +542 -0
- libkp-0.1.0/pyproject.toml +25 -0
- libkp-0.1.0/ruff.toml +11 -0
- libkp-0.1.0/src/libkp/__init__.py +348 -0
- libkp-0.1.0/src/libkp/_broadcast.py +48 -0
- libkp-0.1.0/src/libkp/_generated.py +638 -0
- libkp-0.1.0/src/libkp/_lane.py +111 -0
- libkp-0.1.0/src/libkp/_link.py +155 -0
- libkp-0.1.0/src/libkp/_routes.py +189 -0
- libkp-0.1.0/src/libkp/cbor.py +965 -0
- libkp-0.1.0/src/libkp/control.py +572 -0
- libkp-0.1.0/src/libkp/discovery.py +297 -0
- libkp-0.1.0/src/libkp/errors.py +286 -0
- libkp-0.1.0/src/libkp/midi3.py +122 -0
- libkp-0.1.0/src/libkp/model.py +1456 -0
- libkp-0.1.0/src/libkp/nav.py +152 -0
- libkp-0.1.0/src/libkp/nrpn.py +419 -0
- libkp-0.1.0/src/libkp/params.py +204 -0
- libkp-0.1.0/src/libkp/protocol.py +141 -0
- libkp-0.1.0/src/libkp/registry.py +125 -0
- libkp-0.1.0/src/libkp/session.py +420 -0
- libkp-0.1.0/src/libkp/state.py +1172 -0
- libkp-0.1.0/src/libkp/testing.py +460 -0
- libkp-0.1.0/tests/conftest.py +41 -0
- libkp-0.1.0/tests/test_captures.py +307 -0
- libkp-0.1.0/tests/test_cbor.py +206 -0
- libkp-0.1.0/tests/test_conformance.py +505 -0
- libkp-0.1.0/tests/test_control.py +128 -0
- libkp-0.1.0/tests/test_discovery.py +123 -0
- libkp-0.1.0/tests/test_meters_example.py +256 -0
- libkp-0.1.0/tests/test_meters_tui.py +107 -0
- libkp-0.1.0/tests/test_midi3.py +73 -0
- libkp-0.1.0/tests/test_model.py +1658 -0
- libkp-0.1.0/tests/test_nrpn.py +172 -0
- libkp-0.1.0/tests/test_params.py +104 -0
- libkp-0.1.0/tests/test_protocol.py +61 -0
- libkp-0.1.0/tests/test_public_api.py +22 -0
- libkp-0.1.0/tests/test_registry.py +61 -0
- libkp-0.1.0/tests/test_session.py +414 -0
- libkp-0.1.0/tests/test_state.py +586 -0
libkp-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
/rust/target/
|
|
3
|
+
**/*.rs.bk
|
|
4
|
+
Cargo.lock
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.venv/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
uv.lock
|
|
15
|
+
|
|
16
|
+
# Swift
|
|
17
|
+
/swift/.build/
|
|
18
|
+
/swift/.swiftpm/
|
|
19
|
+
*.xcodeproj
|
|
20
|
+
|
|
21
|
+
# macOS
|
|
22
|
+
.DS_Store
|
|
23
|
+
|
|
24
|
+
# Editors
|
|
25
|
+
.vscode/
|
|
26
|
+
.idea/
|
|
27
|
+
|
libkp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: libkp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Kemper Profiler network protocol: discovery, session, MIDI3/SysEx, control, and an observable device model.
|
|
5
|
+
Author: Aaron Gotwalt
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: kemper,midi,profiler,protocol
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
11
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
12
|
+
Provides-Extra: tui
|
|
13
|
+
Requires-Dist: textual>=0.60; extra == 'tui'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# libkp (Python)
|
|
17
|
+
|
|
18
|
+
The Kemper Profiler network protocol in pure Python: LAN discovery, the TCP
|
|
19
|
+
session handshake, MIDI3 stream framing, the Kemper SysEx/NRPN message grammar,
|
|
20
|
+
the CC control vocabulary, and an observable async device model.
|
|
21
|
+
|
|
22
|
+
- **Python 3.11+**, `asyncio`.
|
|
23
|
+
- **Standard library only** at runtime — no third-party dependencies.
|
|
24
|
+
- Two example front-ends: a zero-dependency ANSI `meters`, and an optional
|
|
25
|
+
**Textual** TUI `meters_tui` (installed via the `tui` extra).
|
|
26
|
+
- `pytest` for the test suite, including the shared cross-language conformance
|
|
27
|
+
vectors and replay-capture fixtures in [`../spec`](../spec).
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install libkp # from PyPI
|
|
33
|
+
pip install -e '.[dev]' # or from python/, to work on it
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or run straight from the source tree:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
PYTHONPATH=src python examples/meters.py --help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## The live meters example
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
python examples/meters.py # discover a device, then render
|
|
46
|
+
python examples/meters.py --ip 192.168.1.50 --all --width 48
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A full-screen ANSI view that updates straight off the stream:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
KEMPER LIVE 192.168.1.50 frames 1284 (20.1/s)
|
|
53
|
+
|
|
54
|
+
RIG Crunchy Vox ♪ 120 BPM
|
|
55
|
+
by Someone
|
|
56
|
+
AMP Test Amp CAB Test Cab
|
|
57
|
+
|
|
58
|
+
● A Green Scream ○ B Compressor · C — · D —
|
|
59
|
+
· X — ● MOD Air Chorus ● DLY Single Delay ● REV Easy Reverb
|
|
60
|
+
|
|
61
|
+
tuner A4 [·········◆··········] ● in tune
|
|
62
|
+
|
|
63
|
+
stack level [████████·············] 9000 range 0-11020
|
|
64
|
+
rig out level [██████···············] 6224 range 0- 9310
|
|
65
|
+
loudness [██···················] 285 range 0- 980
|
|
66
|
+
|
|
67
|
+
last param: Amplifier: Gain = 6925
|
|
68
|
+
(play into the device — Ctrl-C to quit; --all shows every raw field)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Flags: `--ip` (optional; discovery runs when it is omitted), `--port`, `--all`
|
|
72
|
+
(show all eleven raw meter fields), `--width`, `--fps`, `--discover-timeout`.
|
|
73
|
+
Ctrl-C restores the cursor and exits.
|
|
74
|
+
|
|
75
|
+
## The Textual TUI example
|
|
76
|
+
|
|
77
|
+
`meters_tui` is a richer, widget-based terminal UI (rounded panels, colored
|
|
78
|
+
gauges, an effect-block grid, a live tuner strobe) built on
|
|
79
|
+
[Textual](https://textual.textualize.io/). It needs the optional `tui` extra
|
|
80
|
+
(Textual); the library itself stays dependency-free. Run it with `uv` (below),
|
|
81
|
+
or `pip install -e '.[tui]'` then `python examples/meters_tui.py`. Same core
|
|
82
|
+
flags as the ANSI example; press `q` to quit and `a` to toggle the raw fields.
|
|
83
|
+
|
|
84
|
+
## Running the examples with uv
|
|
85
|
+
|
|
86
|
+
[uv](https://docs.astral.sh/uv/) runs either example without a manual
|
|
87
|
+
virtualenv — it resolves the package (and any extras) on the fly. From `python/`:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
# Zero-dependency ANSI meters
|
|
91
|
+
uv run examples/meters.py --help
|
|
92
|
+
uv run examples/meters.py --ip 10.0.0.1 --all
|
|
93
|
+
|
|
94
|
+
# Textual TUI — the `--extra tui` pulls in Textual just for this run
|
|
95
|
+
uv run --extra tui examples/meters_tui.py
|
|
96
|
+
uv run --extra tui examples/meters_tui.py --ip 10.0.0.1
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Pin the interpreter (anything 3.11+) when you want a specific one, and
|
|
100
|
+
materialize the environment once for repeated runs or editor tooling:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
uv run --python 3.14 --extra tui examples/meters_tui.py # choose the interpreter
|
|
104
|
+
uv sync --extra tui # create .venv with Textual
|
|
105
|
+
uv run examples/meters_tui.py # then reuse it
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Only the Textual example needs the `tui` extra; `uv run examples/meters.py`
|
|
109
|
+
needs nothing beyond the standard library.
|
|
110
|
+
|
|
111
|
+
## Quick start
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import asyncio
|
|
115
|
+
from libkp import DeviceModel, find_first
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def main():
|
|
119
|
+
reply = await find_first() # UDP broadcast discovery
|
|
120
|
+
# A bare connect is the whole session: the stream, its read-only sync
|
|
121
|
+
# burst, and the control link that carries the morph position.
|
|
122
|
+
async with await DeviceModel.connect(reply.ip) as model:
|
|
123
|
+
snapshots = model.subscribe() # coalesced state snapshots
|
|
124
|
+
state = await snapshots.get()
|
|
125
|
+
print(state.connection.value, state.rig.name, state.morph)
|
|
126
|
+
|
|
127
|
+
# A tracked parameter, then the read-back that confirms it: the device
|
|
128
|
+
# applies a write silently, and request_param returns what it now holds.
|
|
129
|
+
await model.set_effect_enabled("REV", False)
|
|
130
|
+
rev_on = await model.request_param(0x3D, 3) # 0
|
|
131
|
+
|
|
132
|
+
await model.tap_tempo() # a momentary action
|
|
133
|
+
model.step_rig(1) # aim at the next rig; the Navigator loads it
|
|
134
|
+
|
|
135
|
+
state = await snapshots.get()
|
|
136
|
+
print(state.navigation.aim, state.aimed_rig_index)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
asyncio.run(main())
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Discovery needs UDP 5727 **exclusively** — the device replies only to that
|
|
143
|
+
port, and the kernel hands each reply to just one bound socket, so a second
|
|
144
|
+
listener steals replies rather than copying them. Acquiring it fails fast if
|
|
145
|
+
another program (Kemper's Rig Manager, typically) holds it. Hold a
|
|
146
|
+
`DiscoveryPort` across a session rather than re-acquiring per attempt; see
|
|
147
|
+
[Discovery](../docs/02-discovery.md#owning-the-port).
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from libkp import DiscoveryPort
|
|
151
|
+
|
|
152
|
+
with DiscoveryPort.acquire() as port: # raises PortUnavailableError if taken
|
|
153
|
+
replies = await port.poll()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Layout
|
|
157
|
+
|
|
158
|
+
| Module | What it does |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `libkp.protocol` | The TagStream wire encoding and the 34-byte discovery poll. |
|
|
161
|
+
| `libkp.discovery` | Async UDP broadcast discovery (`discover`, `find_first`). |
|
|
162
|
+
| `libkp.session` | TCP connect plus the line-based protocol-selection handshake. |
|
|
163
|
+
| `libkp.midi3` | The 4-byte stream framing (`Unframer`, `frame`). |
|
|
164
|
+
| `libkp.cbor` | The native CBOR channel: codec, the `ControlLink` the model runs beside its stream, and two tooling wrappers over it — `fetch_state_snapshot` (a one-shot read of the current bank/rig/morph) and `CborSession` (the raw value stream). |
|
|
165
|
+
| `libkp.nrpn` | Kemper SysEx/NRPN builders and parsers, plus the beacon. |
|
|
166
|
+
| `libkp.control` | The 7-bit CC / PC / Bank Select vocabulary. |
|
|
167
|
+
| `libkp.params` | Offline `page/number → name` lookups. |
|
|
168
|
+
| `libkp.registry` | Typed descriptors and value formatting over those names. |
|
|
169
|
+
| `libkp.state` | The state tree and the pure `DeviceState.apply_update` fold. |
|
|
170
|
+
| `libkp.model` | `DeviceModel`, the async store over the stream and the control link. |
|
|
171
|
+
| `libkp.errors` | The exception family, all deriving from `LibKPError`. |
|
|
172
|
+
| `libkp.testing` | `FakeDevice`, an in-process Profiler to test against. |
|
|
173
|
+
| `libkp._generated` | **Generated, data only** — constants and lookup tables. Do not edit. |
|
|
174
|
+
|
|
175
|
+
`_generated.py` is emitted from [`../spec`](../spec) by
|
|
176
|
+
[`../codegen/generate.py`](../codegen). Every constant and table in this package
|
|
177
|
+
comes from there; the protocol logic is hand-written and held to the shared
|
|
178
|
+
vectors.
|
|
179
|
+
|
|
180
|
+
## The device model is a store
|
|
181
|
+
|
|
182
|
+
`DeviceModel` is the only object in libkp that holds a socket to the device.
|
|
183
|
+
It owns a MIDI3 **stream link** and, by default, a CBOR **control link**, and
|
|
184
|
+
both feed one fold that is the single writer of the state tree — so an app sees
|
|
185
|
+
one handle, one tree, one event stream, and never a channel name except in the
|
|
186
|
+
`ChannelChanged` event.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from libkp import ConnectOptions, ControlPolicy, DeviceModel, SyncStrategy
|
|
190
|
+
|
|
191
|
+
model = await DeviceModel.connect(ip) # both links, the request burst
|
|
192
|
+
model = await DeviceModel.connect(
|
|
193
|
+
ip, options=ConnectOptions(control=ControlPolicy.OFF, sync=SyncStrategy.OFF)
|
|
194
|
+
) # the stream alone, and ask for nothing
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`ConnectOptions` carries the `port`, the `control` policy (`BEST_EFFORT` opens
|
|
198
|
+
the control link beside the stream and degrades the connection if it fails;
|
|
199
|
+
`REQUIRED` fails the connect instead; `OFF` never opens it), the `sync`
|
|
200
|
+
strategy (`STREAM_BURST` asks for every `request = true` row of the routing
|
|
201
|
+
table — 46 read-only requests, all answered in ~50 ms; `OFF` asks for nothing),
|
|
202
|
+
and a `ReconnectPolicy` (see below). A `port` passed positionally overrides
|
|
203
|
+
`options.port`.
|
|
204
|
+
|
|
205
|
+
`DeviceModel` classifies state into two lanes:
|
|
206
|
+
|
|
207
|
+
- **FAST** — the eleven-value realtime meter block, the beat pulse, and tuner
|
|
208
|
+
deviance. Poll `model.status()` once per animation frame.
|
|
209
|
+
- **SLOW** — rig, amp, cabinet, effect slots, output volumes, tempo, morph,
|
|
210
|
+
tuner note, position, connection and channels. Each change queues one fresh
|
|
211
|
+
`DeviceState` snapshot on every `model.subscribe()` queue, coalesced to at
|
|
212
|
+
most one per ingested chunk on either wire; joining broadcasts one fresh
|
|
213
|
+
snapshot so a new subscriber starts from the current tree.
|
|
214
|
+
|
|
215
|
+
`model.events()` gives the granular delta stream (every `DeviceEvent`, fast ones
|
|
216
|
+
included), and `model.add_event_listener(cb)` does the same through a callback.
|
|
217
|
+
Subscriber queues drop their oldest item rather than block the ingest task.
|
|
218
|
+
|
|
219
|
+
Commands split into two groups:
|
|
220
|
+
|
|
221
|
+
- **Parameters** (`set_gain`, `set_rig_volume`, `set_main_volume`,
|
|
222
|
+
`set_monitor_volume`, `set_effect_enabled`, `set_effect_mix`, `set_tempo_bpm`,
|
|
223
|
+
`set_param`) go out as 14-bit NRPN `$01` Single Parameter Changes. The device
|
|
224
|
+
applies them silently and does not echo them, so follow a set with
|
|
225
|
+
`request_param()` when `model.state()` should confirm the new value.
|
|
226
|
+
- **Actions** (`bank`, `tap_tempo`, `tuner_mode`, `morph_button`, `freeze`,
|
|
227
|
+
`rotary_fast`, `delay_infinity`, `effect_button`, the pedals, and
|
|
228
|
+
`send_control`) are momentary 7-bit Control Changes and are not reflected in
|
|
229
|
+
state. `bank(n)` is the bank *preselect* (CC47) alone: it loads nothing.
|
|
230
|
+
- **Navigation** (`navigate_to`, `step_rig`, `step_bank`, `select_slot`) is the
|
|
231
|
+
one way to load a rig; see below.
|
|
232
|
+
|
|
233
|
+
### The Navigator
|
|
234
|
+
|
|
235
|
+
A rig load is the one command that can wedge the device: a second load
|
|
236
|
+
arriving while the first is still landing leaves it on a delayed fuse that
|
|
237
|
+
only a power cycle clears. So nothing in libkp sends a load directly.
|
|
238
|
+
`send_control` refuses `LoadSlot`, `Up`, `Down`, `ProgramChange` and
|
|
239
|
+
`BankSelect`, and `send_raw` refuses a Program Change or a Control Change on
|
|
240
|
+
one of `RIG_LOAD_CONTROLLERS` (CC48–54), both with
|
|
241
|
+
`RigLoadRequiresNavigatorError` before a byte is written. A client *aims*
|
|
242
|
+
instead, and returns at once:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
model.navigate_to(123) # flat, 0-based: bank 25, slot 4
|
|
246
|
+
model.step_rig(+1) # from state.aimed_rig_index, floored at 0
|
|
247
|
+
model.step_bank(forward=False) # one bank down, same slot
|
|
248
|
+
model.select_slot(2) # slot 1-5 of the aimed bank
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`step_rig`, `step_bank` and `select_slot` do nothing while no position is
|
|
252
|
+
known (there is nothing to step from), and a step that lands where the aim
|
|
253
|
+
already is sends nothing.
|
|
254
|
+
|
|
255
|
+
The Navigator serialises the loads. The first aim goes out at once as the
|
|
256
|
+
documented pair — the absolute bank preselect (CC47) then the slot load
|
|
257
|
+
(CC50–54) that commits it — and is *in flight* for `RIG_LOAD_SETTLE_MS`
|
|
258
|
+
(500); every aim that arrives meanwhile only moves the target, and when the
|
|
259
|
+
settle elapses the final target is sent, once. A burst of taps therefore costs
|
|
260
|
+
two loads however long it is, and an index already on the wire is never sent
|
|
261
|
+
again while it stands. The device reports its position on both wires as it
|
|
262
|
+
lands, and a report that matches the aim retires it (`NavigationSettled`); a
|
|
263
|
+
report that does not is ignored, and an aim the device never confirms — one
|
|
264
|
+
past the last rig, where it stays put and says so — is dropped
|
|
265
|
+
`PENDING_WINDOW_MS` (1500) after its move settled (`NavigationDropped`, reason
|
|
266
|
+
`NavDrop.UNCONFIRMED`). The settle is never shortened by an early report: it
|
|
267
|
+
is the measured time the device needs. An aim while the stream is down is
|
|
268
|
+
dropped the same way rather than raising; a stream loss or `close()` clears
|
|
269
|
+
the aim silently.
|
|
270
|
+
|
|
271
|
+
`state.navigation` carries the `aim` and whether a load is `in_flight`, and
|
|
272
|
+
changing either is a slow change (a snapshot). `state.aimed_rig_index` is the
|
|
273
|
+
aim while there is one, else `current_rig_index` — what a rig browser should
|
|
274
|
+
highlight, and what `step_rig` steps from, so a run of taps counts from the
|
|
275
|
+
last tap.
|
|
276
|
+
|
|
277
|
+
The machine behind all of this is `libkp.nav.NavigatorState`, pure and
|
|
278
|
+
public: four fields (`aim`, `sent`, `in_flight`, `awaiting`) and four inputs
|
|
279
|
+
(`navigate`, `settle_elapsed`, `window_elapsed`, `position`), each returning
|
|
280
|
+
the `NavAction` list the model carries out (`Send`, `StartSettle`,
|
|
281
|
+
`StartWindow`, `Settled`, `Dropped`). `spec/vectors/navigation.json` pins it in
|
|
282
|
+
every language.
|
|
283
|
+
|
|
284
|
+
### Requests
|
|
285
|
+
|
|
286
|
+
`request_param`, `request_string`, `request_ext_param`, `request_ext_string` and
|
|
287
|
+
`request_render` are request/reply: each returns the value that answers it, and
|
|
288
|
+
the reply folds into the tree on its way. They go through a request lane that
|
|
289
|
+
keeps at most `MAX_IN_FLIGHT_REQUESTS` (16) on the wire — the rest queue, none
|
|
290
|
+
are dropped — and a request unanswered after `REQUEST_TIMEOUT_MS` (300) raises
|
|
291
|
+
`RequestTimeoutError`, raises a `RequestTimedOut` event, and is never retried:
|
|
292
|
+
the device ignores an address it cannot answer. The morph position is the one
|
|
293
|
+
address the stream cannot read, so asking for it raises
|
|
294
|
+
`RequestUnreadableError` without a byte on the wire — checked before the
|
|
295
|
+
stream's own state, since it is true of the address whether or not the stream
|
|
296
|
+
is up. `request_param` also raises it after the fact for a reply wider than
|
|
297
|
+
the 14 bits a `$01` carries: only a value from the other wire resolving the
|
|
298
|
+
same address could be, and it is not the stream's answer.
|
|
299
|
+
|
|
300
|
+
`refresh()` is the whole burst on demand; `refresh_rig()`, `refresh_bank()` and
|
|
301
|
+
`refresh_position()` are its subsets (the rig strings and each slot's
|
|
302
|
+
Type/On-Off; the bank's five-slot name preview; the current bank and rig slot).
|
|
303
|
+
All return once every reply has landed, or raise the first timeout after the
|
|
304
|
+
rest have.
|
|
305
|
+
|
|
306
|
+
### The two channels
|
|
307
|
+
|
|
308
|
+
The device's **current bank and rig position** lives at two extended
|
|
309
|
+
addresses; `refresh_position()` reads both, and the device pushes a `$06` for
|
|
310
|
+
whichever changes on every rig change — including changes made at the front
|
|
311
|
+
panel — so `state.current_bank` / `state.current_rig_slot` stay live on their
|
|
312
|
+
own. The **morph position** is CBOR-only and never appears on the MIDI3 stream,
|
|
313
|
+
so it comes from the control link: when the link opens it writes the one item
|
|
314
|
+
that asks for the state dump, folds the dump (the morph, the position and a
|
|
315
|
+
great deal else) into the tree, and then folds the live pushes that keep the
|
|
316
|
+
morph moving. A dump has two sections -- the system state, then the loaded rig
|
|
317
|
+
-- and each closes with a run at `DUMP_END_ADDRESS`, so it is recognised as
|
|
318
|
+
finished by the second such run (`DUMP_END_RUNS`), with `DUMP_SETTLE_MS` as
|
|
319
|
+
the fallback; `SyncCompleted` is raised for each channel when its sync is
|
|
320
|
+
done. The control link takes `PROTOCOL_CBOR_CONTROL` or nothing: a greeting
|
|
321
|
+
that does not offer it is a `ProtocolRejectedError` before any selection is
|
|
322
|
+
written, never a link on some other protocol.
|
|
323
|
+
|
|
324
|
+
`state.connection` summarises both: `CONNECTED` (the stream is up and the
|
|
325
|
+
control link is open, still on its way, or off by policy), `DEGRADED` (the
|
|
326
|
+
stream is up but the control link was asked for and is `UNAVAILABLE` or
|
|
327
|
+
`LOST` — the morph is stale or unknown, nothing else is affected),
|
|
328
|
+
`RECONNECTING` (with `state.reconnect_attempt`), or `DISCONNECTED`.
|
|
329
|
+
`state.channels.stream` / `.control` carry each socket's `ChannelState`, and
|
|
330
|
+
`ConnectionChanged` / `ChannelChanged` events report every transition
|
|
331
|
+
(`Connected` and `Disconnected` are still raised alongside).
|
|
332
|
+
|
|
333
|
+
A lost control link is never reopened on its own: `reopen_control()` does it on
|
|
334
|
+
request, refused with `ChannelTooSoonError` inside `CONTROL_REOPEN_MIN_GAP_MS`
|
|
335
|
+
of the last control open (a link already open or opening is left alone and the
|
|
336
|
+
call returns at once), and `ReconnectPolicy(control_reopen=seconds)` opts
|
|
337
|
+
into one attempt per gap while the stream is up. A lost **stream** closes both
|
|
338
|
+
links and reports `Disconnected` — unless `ReconnectPolicy(stream=Backoff(...))`
|
|
339
|
+
was given, in which case the model reports `RECONNECTING`, waits out the
|
|
340
|
+
backoff (`Backoff.default_stream()` is 4 s doubling to 30 s), and dials the
|
|
341
|
+
whole sequence again on the same handle: same receivers, same tree.
|
|
342
|
+
|
|
343
|
+
Every socket goes through `Session.connect`, whose per-peer ledger waits out
|
|
344
|
+
`CONNECTION_COOLDOWN` from the last open or close to that `(ip, port)` before
|
|
345
|
+
dialing, so neither the control link, a reconnect, `fetch_state_snapshot` nor
|
|
346
|
+
`CborSession.connect` can open a socket inside the cooldown of the last one —
|
|
347
|
+
the connection churn the device does not survive (docs/06, docs/11). The model
|
|
348
|
+
adds no sleeps of its own. Once dialed, `Session.handshake` waits up to
|
|
349
|
+
`HANDSHAKE_TIMEOUT` (`HANDSHAKE_TIMEOUT_MS`, 2000) for the first byte of the
|
|
350
|
+
greeting, and again for the first byte of the reply to the protocol selection,
|
|
351
|
+
before reading the rest of each with the short idle gap: a device that has
|
|
352
|
+
served a few sessions can take most of a second to greet, and a connect must
|
|
353
|
+
not fail on that. A device that never greets raises `TimeoutErrorLibKP` for the
|
|
354
|
+
`"greeting"` phase, reporting that full wait.
|
|
355
|
+
|
|
356
|
+
Both wires feed one fold. `DeviceState.apply` (a MIDI3 message) and
|
|
357
|
+
`DeviceState.apply_cbor` / `apply_cbor_text` (a CBOR numeric or string) are thin
|
|
358
|
+
decoders that build an `Update` — the wire it came from, whether it was pushed
|
|
359
|
+
live or is an item of the CBOR state dump, the flat address, and the decoded
|
|
360
|
+
value — and hand it to `DeviceState.apply_update`, which routes it by the table
|
|
361
|
+
generated from `spec/state.toml`. The table decides everything: which field an
|
|
362
|
+
address writes, how the value decodes and is range-checked, which wire may write
|
|
363
|
+
the row (the control channel's copies of the meter block, beat pulse and tuner
|
|
364
|
+
are dropped; the morph position is the control channel's), whether a repeated
|
|
365
|
+
value is a no-op, and whether the row is FAST (event only) or SLOW (event plus
|
|
366
|
+
snapshot). `begin_dump()` / `end_dump()` bracket a state dump so a value pushed
|
|
367
|
+
live on the stream while the dump streams is not overwritten by the dump's
|
|
368
|
+
stale copy of it.
|
|
369
|
+
|
|
370
|
+
`fetch_state_snapshot(ip)` (a one-shot read of the position and morph) and
|
|
371
|
+
`CborSession` (the raw `(address, value)` stream) remain as tooling; both are
|
|
372
|
+
the model's own `ControlLink` with a different sink. `model.apply_cbor()` is
|
|
373
|
+
deprecated — the model folds the channel itself.
|
|
374
|
+
|
|
375
|
+
## Decoding without a device
|
|
376
|
+
|
|
377
|
+
`DeviceState.apply` is pure — no sockets, no clock — and so are `apply_cbor`,
|
|
378
|
+
`apply_cbor_text` and the `apply_update` they feed:
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
from libkp import Unframer
|
|
382
|
+
from libkp.state import DeviceState
|
|
383
|
+
|
|
384
|
+
state, unframer = DeviceState(), Unframer()
|
|
385
|
+
for message in unframer.push(raw_stream_bytes):
|
|
386
|
+
outcome = state.apply(message)
|
|
387
|
+
for event in outcome.events:
|
|
388
|
+
...
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## Testing against a fake Profiler
|
|
392
|
+
|
|
393
|
+
`libkp.testing.FakeDevice` is a Profiler stand-in that speaks the real
|
|
394
|
+
transport in-process: the greeting, the protocol-selection handshake, the
|
|
395
|
+
preamble, then MIDI3 framing or the CBOR dump. Anything built on libkp can hold
|
|
396
|
+
a session against it in its own suite, with nothing below the socket mocked and
|
|
397
|
+
no device on the desk.
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
from libkp import DeviceModel
|
|
401
|
+
from libkp.testing import FakeDevice, answer_requests
|
|
402
|
+
|
|
403
|
+
fake = await FakeDevice(responder=answer_requests).start()
|
|
404
|
+
model = await DeviceModel.connect("127.0.0.1", port=fake.port)
|
|
405
|
+
...
|
|
406
|
+
await model.close()
|
|
407
|
+
await fake.stop()
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
It can also hang up mid-session, hold back the greeting, or refuse connections
|
|
411
|
+
for a while — the states a reconnect has to survive. libkp's own async tests
|
|
412
|
+
drive it; so does the [Home Assistant
|
|
413
|
+
integration](https://github.com/gotwalt/kemper-homeassistant).
|
|
414
|
+
|
|
415
|
+
## Tests
|
|
416
|
+
|
|
417
|
+
```sh
|
|
418
|
+
python -m pytest # from python/
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
The suite covers:
|
|
422
|
+
|
|
423
|
+
- **Conformance** (`tests/test_conformance.py`) — every file in
|
|
424
|
+
`../spec/vectors`: `u14`, `discovery`, `midi3`, `nrpn`, `controls`, `params`,
|
|
425
|
+
`state`, `cbor` and `navigation` (the Navigator's state machine), plus a
|
|
426
|
+
guard that no vector file is left uncovered.
|
|
427
|
+
- **Replay captures** (`tests/test_captures.py`) — every fixture in
|
|
428
|
+
`../spec/captures`: TagStream discovery replies, and whole MIDI3 streams
|
|
429
|
+
checked for message count, pending bytes, exact messages, decoded status
|
|
430
|
+
frames, the per-function histogram, and the resulting rig/amp/cab names.
|
|
431
|
+
- **Unit tests** for each module, and async tests that drive `Session` and
|
|
432
|
+
`DeviceModel` against an in-process stand-in device (`libkp.testing`, shipped with the package).
|
|
433
|
+
|
|
434
|
+
## Provenance
|
|
435
|
+
|
|
436
|
+
Parameter maps, the SysEx/NRPN grammar, effect types, string tags, the CC map,
|
|
437
|
+
and the beacon follow the **Kemper MIDI Parameter Documentation** and
|
|
438
|
+
**PySwitch**. Discovery, the handshake, MIDI3 framing, session encapsulation,
|
|
439
|
+
and the realtime status field identities come from **observed experimentation**.
|
|
440
|
+
See [`../CREDITS.md`](../CREDITS.md).
|