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.
Files changed (45) hide show
  1. libkp-0.1.0/.gitignore +27 -0
  2. libkp-0.1.0/PKG-INFO +440 -0
  3. libkp-0.1.0/README.md +425 -0
  4. libkp-0.1.0/examples/README.md +39 -0
  5. libkp-0.1.0/examples/meters.py +479 -0
  6. libkp-0.1.0/examples/meters_tui.py +542 -0
  7. libkp-0.1.0/pyproject.toml +25 -0
  8. libkp-0.1.0/ruff.toml +11 -0
  9. libkp-0.1.0/src/libkp/__init__.py +348 -0
  10. libkp-0.1.0/src/libkp/_broadcast.py +48 -0
  11. libkp-0.1.0/src/libkp/_generated.py +638 -0
  12. libkp-0.1.0/src/libkp/_lane.py +111 -0
  13. libkp-0.1.0/src/libkp/_link.py +155 -0
  14. libkp-0.1.0/src/libkp/_routes.py +189 -0
  15. libkp-0.1.0/src/libkp/cbor.py +965 -0
  16. libkp-0.1.0/src/libkp/control.py +572 -0
  17. libkp-0.1.0/src/libkp/discovery.py +297 -0
  18. libkp-0.1.0/src/libkp/errors.py +286 -0
  19. libkp-0.1.0/src/libkp/midi3.py +122 -0
  20. libkp-0.1.0/src/libkp/model.py +1456 -0
  21. libkp-0.1.0/src/libkp/nav.py +152 -0
  22. libkp-0.1.0/src/libkp/nrpn.py +419 -0
  23. libkp-0.1.0/src/libkp/params.py +204 -0
  24. libkp-0.1.0/src/libkp/protocol.py +141 -0
  25. libkp-0.1.0/src/libkp/registry.py +125 -0
  26. libkp-0.1.0/src/libkp/session.py +420 -0
  27. libkp-0.1.0/src/libkp/state.py +1172 -0
  28. libkp-0.1.0/src/libkp/testing.py +460 -0
  29. libkp-0.1.0/tests/conftest.py +41 -0
  30. libkp-0.1.0/tests/test_captures.py +307 -0
  31. libkp-0.1.0/tests/test_cbor.py +206 -0
  32. libkp-0.1.0/tests/test_conformance.py +505 -0
  33. libkp-0.1.0/tests/test_control.py +128 -0
  34. libkp-0.1.0/tests/test_discovery.py +123 -0
  35. libkp-0.1.0/tests/test_meters_example.py +256 -0
  36. libkp-0.1.0/tests/test_meters_tui.py +107 -0
  37. libkp-0.1.0/tests/test_midi3.py +73 -0
  38. libkp-0.1.0/tests/test_model.py +1658 -0
  39. libkp-0.1.0/tests/test_nrpn.py +172 -0
  40. libkp-0.1.0/tests/test_params.py +104 -0
  41. libkp-0.1.0/tests/test_protocol.py +61 -0
  42. libkp-0.1.0/tests/test_public_api.py +22 -0
  43. libkp-0.1.0/tests/test_registry.py +61 -0
  44. libkp-0.1.0/tests/test_session.py +414 -0
  45. 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).