From 63c85dd0a3c82efe64c4d36f12c94a3776130e1e Mon Sep 17 00:00:00 2001 From: Aaron Gotwalt Date: Sat, 22 Aug 2026 16:11:01 -0700 Subject: [PATCH 1/5] Add the CBOR state-snapshot channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device speaks a native CBOR control channel alongside MIDI3. This implements exactly one route on it: the state-dump snapshot that reports the current bank and rig index. The channel's wider management grammar is documented but deliberately not implemented. The snapshot matters because the device never volunteers its rig position over MIDI3, so a client that wants to show where the device actually is has to ask for it once at connect. Reading it needs a second, short-lived TCP connection, which surfaced a device constraint worth encoding: it refuses to greet — or resets — a session opened too soon after a previous socket closed. `CONNECTION_COOLDOWN` (from the shared spec) is the minimum quiet gap, so every implementation spaces the CBOR fetch and the MIDI3 session identically and never overlaps them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S --- docs/06-cbor-channel.md | 168 +++++-- python/src/libkp/cbor.py | 607 +++++++++++++++++++++++++ python/src/libkp/session.py | 12 +- python/tests/test_cbor.py | 74 +++ rust/src/cbor.rs | 722 ++++++++++++++++++++++++++++++ rust/src/session.rs | 13 + spec/vectors/cbor.json | 55 +++ swift/Sources/LibKP/Cbor.swift | 581 ++++++++++++++++++++++++ swift/Sources/LibKP/Session.swift | 12 + 9 files changed, 2210 insertions(+), 34 deletions(-) create mode 100644 python/src/libkp/cbor.py create mode 100644 python/tests/test_cbor.py create mode 100644 rust/src/cbor.rs create mode 100644 spec/vectors/cbor.json create mode 100644 swift/Sources/LibKP/Cbor.swift diff --git a/docs/06-cbor-channel.md b/docs/06-cbor-channel.md index d952b59..93541a7 100644 --- a/docs/06-cbor-channel.md +++ b/docs/06-cbor-channel.md @@ -1,14 +1,18 @@ # The CBOR channel -> **Experimental — not implemented in `libkp`.** -> This document records what is known about the device's native control channel -> from the outside. It is deliberately incomplete: only the wire shape has been -> characterized, not the command grammar. Nothing here is exercised by the -> conformance vectors, and no `libkp` code path speaks it. +> **Partially implemented.** `libkp` speaks this channel for exactly one purpose: +> the **state-dump snapshot** that reads the device's current bank and rig +> ([`cbor`](../rust/src/cbor.rs) / [`libkp.cbor`](../python/src/libkp/cbor.py) / +> [`Cbor`](../swift/Sources/LibKP/Cbor.swift)). The codec (encode + streaming +> decode) and that one exchange are exercised by the conformance vectors +> ([`../spec/vectors/cbor.json`](../spec/vectors/cbor.json)). The channel's wider +> command grammar — preset and library management, backup, firmware transfer — is +> still only characterized as a category and no `libkp` code path drives it. > -> If you need to read or write parameters, use the MIDI3 stream -> ([04](04-midi3-framing.md), [05](05-sysex-nrpn.md)). It covers the entire -> control and monitoring surface this library exposes. +> For live control and monitoring, use the MIDI3 stream +> ([04](04-midi3-framing.md), [05](05-sysex-nrpn.md)); it covers the whole +> realtime surface. The one thing it cannot report is the device's current +> position, which is what this channel is used for. ## What it is @@ -32,16 +36,30 @@ self-description supplies the boundaries. A decoder reads one item at a time from the byte stream and hands each to the application; a well-formed capture decodes to a whole number of items with no residue. -Every item observed has the same shape: a **`tag(1)`** wrapping a small array of -integers. +Every parameter item observed is a **`tag(1)`** wrapping a small array whose +first element (after an optional leading negative source-flag word) selects the +shape: -| Shape | Notes | -|---|---| -| `tag(1)([1, addr, value])` | the common case — one parameter event | -| `tag(1)([e, 1, addr, value])` | a 4-element variant with a leading element (observed as `-1`); its meaning is not characterized | +| Shape | Selector | Notes | +|---|---|---| +| `tag(1)([1, addr, value])` | 1 | one parameter — a single change or reply | +| `tag(1)([2, base, v0, v1, …])` | 2 | a **consecutive run**: `base`, `base+1`, … | +| `tag(1)([4, addr, "text"])` | 4 | a string parameter | +| `tag(1)([-1, 1, addr, value])` | — | the single-parameter shape with a leading source flag (observed as `-1`) | `addr` and `value` are plain CBOR integers, so their encoded width varies with -magnitude — this is not a fixed-size record format. +magnitude — this is not a fixed-size record format. The encoder writes the +**shortest integer head that fits**, which is what the device itself emits +(address 102405 as `1a 00 01 90 05`, 15953 as `19 3e 51`). + +### Filler bytes + +Between top-level items the channel emits runs of the padding byte **`0xC0`** — a +`tag(0)` head with no content, which is not well-formed CBOR on its own. Parsed +naively it swallows the following item, so the decoder skips it. A *genuine* +`tag(0)` is an RFC 8949 date/time whose content must be a text string, so a +`0xC0` is treated as filler only when the next head is **not** a text head, +leaving real datetimes intact. ## Addressing @@ -57,35 +75,119 @@ which is the identical formula the `$06`/`$07` extended messages use for their `(page, number)` decomposition — appear in this space too, so the channel's address field spans the full extended range rather than just the 14-bit part. -Worked examples, decoded against known addresses: - | `addr` | Decomposition | Parameter | |---|---|---| | 15872 | `$7C` × 128 + 0 | Tempo / beat pulse — toggles 0 / 16383 | | 15953 | `$7C` × 128 + 81 | Tuner Strobe Phase — reads 0 while idle | | 102405 | ≥ 16384, extended | an extended-address parameter with no page/number form | +| 100701 | ≥ 16384, extended | **current bank**, 0-based (see below) | +| 100702 | ≥ 16384, extended | **current rig slot** in the bank, 0-based | + +## The state dump — and how to ask for it + +The channel does **not** volunteer the device's stored state on connect. A +passive session — one that completes the handshake and preamble and then only +listens — sees just the live change events physical knob turns produce; it never +learns the starting value of anything it did not watch move. + +Writing one item asks for the whole thing: + +``` +tag(1)([1, 102528, 1]) -> c1 83 01 1a 00 01 90 80 01 +``` + +`102528` is `state_dump_trigger_address`; the value is `1`. The device answers +with its **entire parameter state** as a burst of selector-`2` runs and +selector-`4` strings. The write is **non-mutating** — `102528` is a status flag +the device already carries — so a read-only client may send it safely. This one +item is sufficient by itself. + +### Current bank and rig + +The dump carries the device's current position as two 0-based extended +addresses: + +| address | meaning | +|---|---| +| **100701** (`0x1895D`) | current bank, 0-based | +| **100702** (`0x1895E`) | current rig slot in the bank, 0-based | + +At session open they arrive together inside one consecutive run — e.g. +`tag(1)([2, 100700, 0, 0, 2])` sets `100700=0`, `100701=0`, `100702=2` — so a +reader must **walk the whole run** rather than assume a fixed position; live +changes then push the elements singly. The dump also carries the current rig +name (string address 1) and the bank's five preview names, so the name and the +index agree. + +This is the only route to these values. The MIDI3 stream never reports them: the +read-only name-match ([Control model](08-control-model.md)) — comparing the +loaded rig name against the [bank preview](09-parameter-registry.md) — can +recover the *slot* but not the bank number, and fails when two slots share a +name. The numeric indices resolve both. + +### Credentials are redacted + +The dump also volunteers the device's stored WiFi credentials in the clear — the +network name at address 200008 and its passphrase at 200009 (`sensitive_addresses`). +The snapshot reader replaces any string value at these addresses with +`[redacted]` (`redacted_placeholder`) before it is exposed, and nothing in +`libkp` surfaces them. A reader built on the raw [`cbor`](../rust/src/cbor.rs) +codec must do the same. + +## The snapshot API + +One call opens a fresh, short-lived CBOR session, sends the trigger, reads the +current position out of the dump, and closes: + +```rust +use libkp::cbor::StateSnapshot; +let snap = StateSnapshot::fetch(ip).await?; +// snap.current_bank, snap.current_rig_slot (both Option, 0-based) +``` + +```python +from libkp import fetch_state_snapshot +snap = await fetch_state_snapshot(ip) +# snap.current_bank, snap.current_rig_slot (both int | None, 0-based) +``` + +```swift +let snap = try await StateSnapshot.fetch(ip) +// snap.currentBank, snap.currentRigSlot (both UInt16?, 0-based) +``` -Address 102405 also appears as an `$06` Extended Parameter Change on the MIDI3 -stream — see the worked unframing example in -[MIDI3 framing](04-midi3-framing.md). The two channels are addressing the same -thing. +**Run it before, not alongside, a streaming session.** It opens its own socket, +independent of a [`DeviceModel`](07-realtime-status.md); the device is unhappy +about concurrent connections and connection churn. It refuses to greet — or +resets — a session opened too soon after a prior socket closed: a MIDI3 session +opened immediately after the snapshot's socket closes times out waiting for the +greeting, while spacing them by about a second connects cleanly. So a controller +that also wants live meters should fetch the snapshot first, **wait at least the +connection cooldown** (`connection_cooldown_ms`, exposed as `CONNECTION_COOLDOWN` +in Rust/Python and `Session.connectionCooldown` in Swift), let that socket close, +then open the MIDI3 model — and feed the result in with +`DeviceModel::set_current_position` (Rust) / `set_current_position` (Python) / +`setCurrentPosition` (Swift), which folds the indices into `DeviceState` +(`current_bank` / `current_rig_slot`) and emits a `CurrentPosition` event. ## The relationship to MIDI3 -In an idle session, everything this channel pushes is a re-encoding of events -the device already broadcasts as MIDI3-framed SysEx. One event universe, two -wire formats: +In an idle session, the change events this channel pushes are a re-encoding of +events the device already broadcasts as MIDI3-framed SysEx. One event universe, +two wire formats: | | `{369F50E7-…}` MIDI3 | `{774CDB9E-…}` CBOR | |---|---|---| | Framing | 4-byte frames → Kemper SysEx | bare CBOR items | | Parameter change | `$01` at `/` | `tag(1)([1, addr, value])` | -| Beat pulse | `$01` page `$7C`, number 0 | the 4-element variant at addr 15872 | +| Consecutive run | `$02` multi-parameter | `tag(1)([2, base, …])` | +| String | `$03`/`$43` | `tag(1)([4, addr, "text"])` | | Extended parameter | `$06` with a 5-byte address | the same integer address | -| Realtime status block | `$02` at `$7C`/`$4E` | not observed | +| Full state dump | not observed | the selector-`2`/`4` burst above | The channels are independent sessions on independent sockets. Selecting one -during the handshake precludes the other for that connection. +during the handshake precludes the other for that connection — and the full +state dump is something only this channel is known to produce. ## Encodings do not cross over @@ -103,11 +205,10 @@ this channel must be a CBOR encoder and decoder from the start. Explicitly out of scope of this document: -- The request and command grammar — how to *ask* for something rather than - observe what is pushed. No request shape has been characterized. -- Whether the channel needs a subscription or handshake step analogous to the - MIDI3 [beacon](05-sysex-nrpn.md#beacon-and-sense) before it will answer. -- The semantics of the leading element in the 4-element variant. +- The request and command grammar beyond the state-dump trigger — how to *ask* + for anything else rather than observe what is pushed. No other request shape + has been characterized. +- The semantics of the leading source-flag element in the 4-element variant. - The realtime status block's representation, if it has one here. - Every device-management operation: preset and rig management, library organization, backup, and firmware transfer. @@ -124,6 +225,7 @@ but its grammar is uncharacterized and it is likewise not implemented. ## Sources -The existence, wire shape, and address space of the CBOR channel were +The existence, wire shape, address space, filler convention, the state-dump +trigger, and the current-position addresses of the CBOR channel were characterized through observed experimentation, from the outside only. See [../CREDITS.md](../CREDITS.md). diff --git a/python/src/libkp/cbor.py b/python/src/libkp/cbor.py new file mode 100644 index 0000000..00b9ccb --- /dev/null +++ b/python/src/libkp/cbor.py @@ -0,0 +1,607 @@ +"""The device's native CBOR control channel and its state-dump snapshot. + +The ``{774CDB9E-…}`` protocol GUID (:data:`libkp.session.PROTOCOL_CBOR_CONTROL`) +completes the same :mod:`handshake ` and 8-byte preamble as the +MIDI3 stream, then speaks **CBOR** (RFC 8949) rather than MIDI3 frames — a +continuous sequence of bare items with no outer framing (see ``docs/06``). This +module is the reader and writer for that channel: + +- Decoded items are plain Python objects (``int``, ``bytes``, ``str``, ``list``) + plus a small :class:`Tag` for CBOR tags; :class:`Decoder` is a streaming reader + that buffers partial items across TCP chunk boundaries and tolerates the + inter-item filler the channel emits. +- :func:`encode` / :func:`to_vec` and :func:`param_write` write the one item this + library sends — the write that asks the device for its full state. +- :func:`extract_snapshot` reads the device's current bank and rig slot out of + the resulting dump, and :func:`fetch_state_snapshot` runs the whole exchange + over a fresh session. + +The channel does not volunteer state: a passive session sees only live change +events. Writing ``param_write(STATE_DUMP_TRIGGER_ADDRESS, 1)`` asks for the whole +parameter state, which arrives as a burst of multi-parameter and string items +carrying — among much else — the current bank +(:data:`libkp._generated.CURRENT_BANK_ADDRESS`) and rig slot +(:data:`libkp._generated.CURRENT_RIG_SLOT_ADDRESS`), both 0-based. The write is +non-mutating. +""" + +from __future__ import annotations + +import asyncio +import struct +from dataclasses import dataclass, field + +from . import _generated as gen +from .errors import ConnectionClosedError +from .protocol import PORT +from .session import PROTOCOL_CBOR_CONTROL, Session + +__all__ = [ + "Tag", + "Decoder", + "encode", + "to_vec", + "param_write", + "state_dump_request", + "is_sensitive", + "StateSnapshot", + "extract_snapshot", + "fetch_state_snapshot", +] + +#: Nesting limit — guards the recursive descent against hostile/desynced input. +_MAX_DEPTH = 32 + +#: Read idle gap driving the snapshot read loop; short so it reacts per packet. +_READ_IDLE = 0.03 + + +@dataclass(frozen=True, slots=True) +class Tag: + """A CBOR tag: a tag number wrapping one inner value.""" + + tag: int + value: object + + +class _Undefined: + """The CBOR ``undefined`` simple value (major type 7, value 23).""" + + __slots__ = () + + def __repr__(self) -> str: # pragma: no cover - debug aid + return "undefined" + + +class _Break: + """The ``0xFF`` break stop-code; only valid inside an indefinite-length item.""" + + __slots__ = () + + def __repr__(self) -> str: # pragma: no cover - debug aid + return "break" + + +@dataclass(frozen=True, slots=True) +class Simple: + """A CBOR simple value (major type 7) other than bool/null/undefined.""" + + value: int + + +UNDEFINED = _Undefined() +BREAK = _Break() + + +class _Incomplete(Exception): + """Ran out of bytes mid-item — wait for more of the stream.""" + + +class _Invalid(Exception): + """Not valid CBOR (reserved additional-info, bad UTF-8, nesting too deep).""" + + +def _as_int(v: object) -> int | None: + """The item as an integer, for the ``[selector, addr, value]`` shapes. + + ``bool`` is excluded — it is a distinct CBOR type here even though Python + makes it a subclass of ``int``. + """ + if isinstance(v, bool): + return None + return v if isinstance(v, int) else None + + +def _as_array(v: object) -> list | None: + """The elements, if ``v`` is an array (unwrapping one layer of tag first).""" + if isinstance(v, list): + return v + if isinstance(v, Tag): + return _as_array(v.value) + return None + + +def _is_text_head(b: int) -> bool: + """Heads that open a text string (major type 3), definite or indefinite.""" + return b >> 5 == 3 + + +class Decoder: + """A streaming CBOR reader: feed it TCP chunks, take whole items back. + + The channel pads between top-level items with runs of the filler byte + :data:`libkp._generated.CBOR_FILLER_BYTE` (``0xC0``) — a ``tag(0)`` head with + no content, which is not well-formed CBOR on its own. Parsed naively it + swallows the following item, so the decoder skips it. A genuine ``tag(0)`` is + an RFC 8949 date/time whose content must be a text string, so a ``0xC0`` is + treated as filler only when the next head is *not* a text head, leaving real + datetimes intact. + """ + + __slots__ = ("_buf", "_filler") + + def __init__(self) -> None: + self._buf = bytearray() + self._filler = 0 + + def push(self, data: bytes) -> list: + """Feed raw stream bytes; return every item completed by this input. + + A byte that cannot start a valid item is dropped to resync (the same + strategy :class:`libkp.midi3.Unframer` uses), so a mid-stream join or a + wrong-protocol guess degrades to noise rather than a permanent stall. + """ + self._buf.extend(data) + out: list = [] + off = 0 + while True: + # ``_skip_filler`` returning None means a trailing filler byte we + # cannot classify until more bytes arrive — stop and keep it buffered. + nxt = self._skip_filler(off) + if nxt is None: + break + off = nxt + try: + value, used = _parse_item(self._buf, off, 0) + except _Incomplete: + break + except _Invalid: + off += 1 # resync + continue + off += used + out.append(value) + del self._buf[:off] + return out + + def pending(self) -> int: + """Bytes buffered but not yet forming a complete item.""" + return len(self._buf) + + def filler_bytes(self) -> int: + """How many filler bytes have been skipped over this stream's life.""" + return self._filler + + def _skip_filler(self, off: int) -> int | None: + """Step ``off`` past any inter-item filler. ``None`` means the buffer ends + on a filler byte whose role cannot be decided until the next byte arrives. + """ + while off < len(self._buf) and self._buf[off] == gen.CBOR_FILLER_BYTE: + if off + 1 >= len(self._buf): + return None + if _is_text_head(self._buf[off + 1]): + return off # real datetime tag + off += 1 + self._filler += 1 + return off + + +def _parse_item(b: bytes, off: int, depth: int) -> tuple[object, int]: + """Parse one item from ``b`` at ``off``. Returns the item and bytes consumed.""" + if depth > _MAX_DEPTH: + raise _Invalid + if off >= len(b): + raise _Incomplete + head = b[off] + major = head >> 5 + ai = head & 0x1F + + # Additional info 31 = indefinite length (or the break stop-code). + if ai == 31: + if major == 2: + return _parse_indefinite_chunks(b, off, depth, bytes_mode=True) + if major == 3: + return _parse_indefinite_chunks(b, off, depth, bytes_mode=False) + if major == 4: + return _parse_indefinite_array(b, off, depth) + if major == 5: + return _parse_indefinite_map(b, off, depth) + if major == 7: + return BREAK, 1 + raise _Invalid + + arg, head_len = _parse_argument(b, off, ai) + rest = off + head_len + + if major == 0: + return arg, head_len + if major == 1: + return -1 - arg, head_len + if major in (2, 3): + if rest + arg > len(b): + raise _Incomplete + raw = bytes(b[rest : rest + arg]) + if major == 2: + return raw, head_len + arg + try: + return raw.decode("utf-8"), head_len + arg + except UnicodeDecodeError as exc: + raise _Invalid from exc + if major == 4: + items: list = [] + pos = rest + for _ in range(arg): + v, used = _parse_item(b, pos, depth + 1) + pos += used + items.append(v) + return items, pos - off + if major == 5: + pairs: list = [] + pos = rest + for _ in range(arg): + k, used = _parse_item(b, pos, depth + 1) + pos += used + v, used = _parse_item(b, pos, depth + 1) + pos += used + pairs.append((k, v)) + return {"map": pairs}, pos - off + if major == 6: + inner, used = _parse_item(b, rest, depth + 1) + return Tag(arg, inner), head_len + used + if major == 7: + return _parse_simple(ai, arg, head_len) + raise _Invalid + + +def _parse_argument(b: bytes, off: int, ai: int) -> tuple[int, int]: + """Read the head byte's argument. Returns ``(value, total head length)``.""" + if ai <= 23: + return ai, 1 + width = {24: 1, 25: 2, 26: 4, 27: 8}.get(ai) + if width is None: # 28..=30 are reserved + raise _Invalid + if off + 1 + width > len(b): + raise _Incomplete + arg = 0 + for x in b[off + 1 : off + 1 + width]: + arg = (arg << 8) | x + return arg, 1 + width + + +def _parse_simple(ai: int, arg: int, head_len: int) -> tuple[object, int]: + """Major type 7: booleans, null, undefined, simple values and floats.""" + if ai == 20: + return False, head_len + if ai == 21: + return True, head_len + if ai == 22: + return None, head_len + if ai == 23: + return UNDEFINED, head_len + if ai <= 19 or ai == 24: + return Simple(arg), head_len + if ai == 25: + return _f16_to_float(arg), head_len + if ai == 26: + return struct.unpack(">f", struct.pack(">I", arg))[0], head_len + if ai == 27: + return struct.unpack(">d", struct.pack(">Q", arg))[0], head_len + raise _Invalid + + +def _parse_indefinite_chunks( + b: bytes, off: int, depth: int, *, bytes_mode: bool +) -> tuple[object, int]: + """Indefinite-length byte/text string: definite chunks until the break code.""" + pos = off + 1 + acc = bytearray() + while True: + if pos >= len(b): + raise _Incomplete + if b[pos] == 0xFF: + pos += 1 + break + chunk, used = _parse_item(b, pos, depth + 1) + if bytes_mode and isinstance(chunk, bytes): + acc.extend(chunk) + elif not bytes_mode and isinstance(chunk, str): + acc.extend(chunk.encode("utf-8")) + else: + raise _Invalid # chunks must match the outer type + pos += used + if bytes_mode: + return bytes(acc), pos - off + try: + return acc.decode("utf-8"), pos - off + except UnicodeDecodeError as exc: + raise _Invalid from exc + + +def _parse_indefinite_array(b: bytes, off: int, depth: int) -> tuple[object, int]: + pos = off + 1 + items: list = [] + while True: + if pos >= len(b): + raise _Incomplete + if b[pos] == 0xFF: + pos += 1 + break + v, used = _parse_item(b, pos, depth + 1) + pos += used + items.append(v) + return items, pos - off + + +def _parse_indefinite_map(b: bytes, off: int, depth: int) -> tuple[object, int]: + pos = off + 1 + pairs: list = [] + while True: + if pos >= len(b): + raise _Incomplete + if b[pos] == 0xFF: + pos += 1 + break + k, used = _parse_item(b, pos, depth + 1) + pos += used + v, used = _parse_item(b, pos, depth + 1) + pos += used + pairs.append((k, v)) + return {"map": pairs}, pos - off + + +def _f16_to_float(bits: int) -> float: + """IEEE-754 half precision → float (CBOR additional info 25).""" + sign = -1.0 if bits & 0x8000 else 1.0 + exp = (bits >> 10) & 0x1F + frac = float(bits & 0x03FF) + if exp == 0: + mag = frac * 2.0**-24 # subnormal + elif exp == 31: + mag = float("inf") if frac == 0.0 else float("nan") + else: + mag = (frac / 1024.0 + 1.0) * 2.0 ** (exp - 15) + return sign * mag + + +# -------------------------------------------------------------------------- +# Encoding +# -------------------------------------------------------------------------- + + +def _write_head(major: int, arg: int, out: bytearray) -> None: + """Write a CBOR head: the 3-bit major type plus the shortest argument encoding + that fits, which is what the device itself emits.""" + m = major << 5 + if arg <= 23: + out.append(m | arg) + elif arg <= 0xFF: + out.append(m | 24) + out.append(arg) + elif arg <= 0xFFFF: + out.append(m | 25) + out.extend(struct.pack(">H", arg)) + elif arg <= 0xFFFF_FFFF: + out.append(m | 26) + out.extend(struct.pack(">I", arg)) + else: + out.append(m | 27) + out.extend(struct.pack(">Q", arg)) + + +def encode(v: object, out: bytearray) -> None: + """Encode a value as CBOR, appending to ``out``. + + :data:`BREAK` encodes as the bare ``0xFF`` stop code, which is only + well-formed inside an indefinite-length item; this module never produces one, + so encoding a stray ``BREAK`` yields bytes the device would reject. + """ + if isinstance(v, bool): + out.append(0xF5 if v else 0xF4) + elif isinstance(v, int): + if v < 0: + _write_head(1, -1 - v, out) # major 1 encodes -1-n as the argument n + else: + _write_head(0, v, out) + elif isinstance(v, (bytes, bytearray)): + _write_head(2, len(v), out) + out.extend(v) + elif isinstance(v, str): + raw = v.encode("utf-8") + _write_head(3, len(raw), out) + out.extend(raw) + elif isinstance(v, list): + _write_head(4, len(v), out) + for item in v: + encode(item, out) + elif isinstance(v, dict) and "map" in v: + pairs = v["map"] + _write_head(5, len(pairs), out) + for k, val in pairs: + encode(k, out) + encode(val, out) + elif isinstance(v, Tag): + _write_head(6, v.tag, out) + encode(v.value, out) + elif v is None: + out.append(0xF6) + elif isinstance(v, _Undefined): + out.append(0xF7) + elif isinstance(v, Simple): + _write_head(7, v.value, out) + elif isinstance(v, float): + out.append(0xFB) + out.extend(struct.pack(">d", v)) + elif isinstance(v, _Break): + out.append(0xFF) + else: # pragma: no cover - defensive + raise TypeError(f"cannot encode {type(v)!r} as CBOR") + + +def to_vec(v: object) -> bytes: + """Encode a value as a fresh ``bytes`` object.""" + out = bytearray() + encode(v, out) + return bytes(out) + + +def param_write(addr: int, value: int) -> Tag: + """Build a single-parameter write, ``tag(1)([1, addr, value])`` — the shape the + channel uses to set a parameter (``docs/06``). + + This is a **write**: the device applies it and rebroadcasts it to every other + open session. The one this library sends — ``param_write( + STATE_DUMP_TRIGGER_ADDRESS, 1)`` — is non-mutating and asks the device for its + full state. + """ + return Tag(gen.CBOR_ITEM_TAG, [gen.CBOR_SELECTOR_SINGLE, addr, value]) + + +def state_dump_request() -> Tag: + """The item that asks the device for its full parameter state.""" + return param_write(gen.STATE_DUMP_TRIGGER_ADDRESS, gen.STATE_DUMP_TRIGGER_VALUE) + + +# -------------------------------------------------------------------------- +# Snapshot +# -------------------------------------------------------------------------- + + +def is_sensitive(addr: int) -> bool: + """Is ``addr`` one whose string value is a device secret (WiFi credentials)? + + The state dump volunteers these in the clear; a reader must never surface + them. See :data:`libkp._generated.SENSITIVE_ADDRESSES`. + """ + return addr in gen.SENSITIVE_ADDRESSES + + +@dataclass(slots=True) +class StateSnapshot: + """The device's current position and the names carried alongside it, read out + of a state dump. Both indices are 0-based; either is ``None`` if the dump did + not carry it.""" + + #: Current bank, 0-based (:data:`libkp._generated.CURRENT_BANK_ADDRESS`). + current_bank: int | None = None + #: Current rig slot within the bank, 0-based + #: (:data:`libkp._generated.CURRENT_RIG_SLOT_ADDRESS`). + current_rig_slot: int | None = None + #: String parameters the dump carried, as ``(address, text)`` in document + #: order, with any sensitive value redacted. Useful for the current rig name + #: (address 1) and the bank name. + strings: list[tuple[int, str]] = field(default_factory=list) + + def is_complete(self) -> bool: + """True once both indices are known — the reader can stop early.""" + return self.current_bank is not None and self.current_rig_slot is not None + + def string(self, addr: int) -> str | None: + """The string parameter at ``addr``, if the dump carried one.""" + for a, text in self.strings: + if a == addr: + return text + return None + + +def _note_index(snap: StateSnapshot, addr: int, value: int) -> None: + """Record the value at one address into the snapshot's indices.""" + v = value if 0 <= value <= gen.FULL_SCALE else None + if addr == gen.CURRENT_BANK_ADDRESS: + snap.current_bank = v + elif addr == gen.CURRENT_RIG_SLOT_ADDRESS: + snap.current_rig_slot = v + + +def extract_snapshot(items: list) -> StateSnapshot: + """Read the current bank, rig slot and string parameters out of decoded dump + items. + + Scans the two index-bearing shapes: a single ``[1, addr, value]``, and a + consecutive-run ``[2, base, v0, v1, …]`` where the whole run is walked because + the target address can fall anywhere inside it (at session open the position + arrives inside one run). A leading negative source-flag word, if present, is + skipped exactly as :func:`param_write` never emits one. + """ + snap = StateSnapshot() + for item in items: + fields = _as_array(item) + if fields is None: + continue + # Skip a leading negative source-flags word. + first = _as_int(fields[0]) if fields else None + rest = fields[1:] if first is not None and first < 0 else fields + selector = _as_int(rest[0]) if rest else None + if selector is None: + continue + addr = _as_int(rest[1]) if len(rest) > 1 else None + if selector == gen.CBOR_SELECTOR_SINGLE: + value = _as_int(rest[2]) if len(rest) > 2 else None + if addr is not None and value is not None: + _note_index(snap, addr, value) + elif selector == gen.CBOR_SELECTOR_MULTI: + if addr is not None: + for i, raw in enumerate(rest[2:]): + v = _as_int(raw) + if v is not None: + _note_index(snap, addr + i, v) + elif selector == gen.CBOR_SELECTOR_STRING: + text = rest[2] if len(rest) > 2 else None + if addr is not None and isinstance(text, str) and text: + snap.strings.append( + (addr, gen.REDACTED_PLACEHOLDER if is_sensitive(addr) else text) + ) + return snap + + +#: Default time to keep reading the dump before giving up on the indices. +DEFAULT_TIMEOUT = 3.0 + + +async def fetch_state_snapshot( + ip: str, *, port: int = PORT, timeout: float = DEFAULT_TIMEOUT +) -> StateSnapshot: + """Open a fresh CBOR session to ``ip``, trigger the state dump, and read back + the current bank and rig slot. + + This opens its **own** short-lived connection, independent of any + :class:`libkp.model.DeviceModel`, and closes it on return. The device crashes + under connection churn, so run this sequentially — before, not concurrently + with, a streaming session — and sparingly. Returns as soon as both indices are + known or ``timeout`` elapses. + """ + session = await Session.connect(ip, port) + try: + outcome = await session.handshake([PROTOCOL_CBOR_CONTROL], _READ_IDLE) + await session.write_session_preamble() + + decoder = Decoder() + items = decoder.push(outcome.response_tail()) + + # Ask for the full state, then read until both indices land or the + # deadline passes. + await session.write_all(to_vec(state_dump_request())) + + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not extract_snapshot(items).is_complete(): + remaining = deadline - loop.time() + if remaining <= 0: + break + try: + chunk = await session.read_once(min(_READ_IDLE, remaining), 64 * 1024) + except ConnectionClosedError: + break + if chunk: + items.extend(decoder.push(chunk)) + return extract_snapshot(items) + finally: + await session.close() diff --git a/python/src/libkp/session.py b/python/src/libkp/session.py index 8e4e636..dfb5bf6 100644 --- a/python/src/libkp/session.py +++ b/python/src/libkp/session.py @@ -34,6 +34,7 @@ "PROTOCOL_RESERVED", "HANDSHAKE_TERMINATOR", "SESSION_PREAMBLE", + "CONNECTION_COOLDOWN", "HandshakeOutcome", "Session", "parse_protocol_list", @@ -43,7 +44,8 @@ PROTOCOL_MIDI3_STREAM: str = gen.PROTOCOL_MIDI3_STREAM #: Request/response protocol GUID: accepts the handshake but pushes nothing. PROTOCOL_REQUEST_RESPONSE: str = gen.PROTOCOL_REQUEST_RESPONSE -#: Native CBOR control channel — documented, not implemented here. +#: The device's native CBOR control channel — the state-dump snapshot route +#: (:mod:`libkp.cbor`). PROTOCOL_CBOR_CONTROL: str = gen.PROTOCOL_CBOR_CONTROL #: Offered by the device but rejects the handshake. PROTOCOL_RESERVED: str = gen.PROTOCOL_RESERVED @@ -57,6 +59,14 @@ #: Default connect timeout. CONNECT_TIMEOUT: float = float(gen.CONNECT_TIMEOUT_SECS) +#: Minimum quiet gap, in seconds, between one session closing and the next +#: opening. The device refuses to greet — or resets — a session opened too soon +#: after a prior socket closed, so an orchestrator that opens more than one +#: session (the CBOR :func:`~libkp.cbor.fetch_state_snapshot` then a MIDI3 +#: :class:`~libkp.model.DeviceModel`) must space them by at least this and never +#: overlap them. See ``docs/06``. +CONNECTION_COOLDOWN: float = gen.CONNECTION_COOLDOWN_MS / 1000.0 + _GREETING_MAX = 256 diff --git a/python/tests/test_cbor.py b/python/tests/test_cbor.py new file mode 100644 index 0000000..4eb3cc8 --- /dev/null +++ b/python/tests/test_cbor.py @@ -0,0 +1,74 @@ +"""Unit tests for the CBOR channel codec and the state-dump snapshot.""" + +from __future__ import annotations + +from libkp import _generated as gen +from libkp import cbor + + +def test_encodes_with_minimal_length_heads(): + # uint16 address, small value. + assert cbor.to_vec(cbor.param_write(15953, 0)) == bytes.fromhex("c18301193e5100") + # uint32 address (the extended range). + assert cbor.to_vec(cbor.param_write(102405, 19)) == bytes.fromhex("c183011a0001900513") + + +def test_encodes_the_state_dump_request(): + # tag(1)([1, 102528, 1]) — the write that asks for the full state. + assert cbor.to_vec(cbor.state_dump_request()) == bytes.fromhex("c183011a0001908001") + + +def test_round_trips_through_the_decoder(): + for item in (cbor.param_write(102528, 1), cbor.param_write(0, 0), cbor.param_write(16383, -1)): + decoder = cbor.Decoder() + out = decoder.push(cbor.to_vec(item)) + assert out == [item] + assert decoder.pending() == 0 + + +def test_decoder_skips_inter_item_filler(): + data = bytes([gen.CBOR_FILLER_BYTE, gen.CBOR_FILLER_BYTE]) + cbor.to_vec( + cbor.param_write(1412, 8629) + ) + decoder = cbor.Decoder() + assert decoder.push(data) == [cbor.param_write(1412, 8629)] + assert decoder.filler_bytes() == 2 + + +def test_decoder_buffers_a_partial_item_across_chunks(): + full = cbor.to_vec(cbor.param_write(102528, 1)) + decoder = cbor.Decoder() + assert decoder.push(full[:4]) == [] + assert decoder.pending() == 4 + assert decoder.push(full[4:]) == [cbor.param_write(102528, 1)] + assert decoder.pending() == 0 + + +def test_extracts_position_from_a_multi_run(): + run = cbor.Tag(1, [2, 100_700, 0, 1, 2]) + snap = cbor.extract_snapshot([run]) + assert snap.current_bank == 1 + assert snap.current_rig_slot == 2 + assert snap.is_complete() + + +def test_extracts_position_from_single_items(): + items = [cbor.param_write(100_701, 3), cbor.param_write(100_702, 4)] + snap = cbor.extract_snapshot(items) + assert snap.current_bank == 3 + assert snap.current_rig_slot == 4 + + +def test_collects_strings_and_redacts_secrets(): + name = cbor.Tag(1, [4, 1, "Maz 18 Pushed"]) + secret = cbor.Tag(1, [4, gen.SENSITIVE_ADDRESSES[0], "hunter2"]) + snap = cbor.extract_snapshot([name, secret]) + assert snap.string(1) == "Maz 18 Pushed" + assert snap.string(gen.SENSITIVE_ADDRESSES[0]) == gen.REDACTED_PLACEHOLDER + + +def test_empty_snapshot_is_incomplete(): + snap = cbor.extract_snapshot([]) + assert snap.current_bank is None + assert snap.current_rig_slot is None + assert not snap.is_complete() diff --git a/rust/src/cbor.rs b/rust/src/cbor.rs new file mode 100644 index 0000000..ad1257e --- /dev/null +++ b/rust/src/cbor.rs @@ -0,0 +1,722 @@ +//! The device's native CBOR control channel and its state-dump snapshot. +//! +//! The `{774CDB9E-…}` protocol GUID ([`session::PROTOCOL_CBOR_CONTROL`]) completes +//! the same [handshake](crate::session) and 8-byte preamble as the MIDI3 stream, +//! then speaks **CBOR** (RFC 8949) rather than MIDI3 frames — a continuous +//! sequence of bare items with no outer framing (see `docs/06`). This module is +//! the reader and writer for that channel: +//! +//! - [`Value`] is one decoded item; [`Decoder`] is a streaming reader that +//! buffers partial items across TCP chunk boundaries and tolerates the +//! inter-item filler the channel emits. +//! - [`encode`] / [`to_vec`] and [`param_write`] write the one item this library +//! sends — the write that asks the device for its full state. +//! - [`extract_snapshot`] reads the device's current bank and rig slot out of the +//! resulting dump, and [`StateSnapshot::fetch`] runs the whole exchange over a +//! fresh session. +//! +//! The channel does not volunteer state: a passive session sees only live change +//! events. Writing [`param_write`]`(`[`generated::STATE_DUMP_TRIGGER_ADDRESS`]`, +//! 1)` asks for the whole parameter state, which arrives as a burst of +//! multi-parameter and string items carrying — among much else — the current +//! bank ([`generated::CURRENT_BANK_ADDRESS`]) and rig slot +//! ([`generated::CURRENT_RIG_SLOT_ADDRESS`]), both 0-based. The write is +//! non-mutating. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use crate::SessionError; +use crate::generated; +use crate::session::{PROTOCOL_CBOR_CONTROL, Session}; + +/// A decoded CBOR data item. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Uint(u64), + /// Negative integer, held widened so `-1 - u64::MAX` still fits. + Neg(i128), + Bytes(Vec), + Text(String), + Array(Vec), + Map(Vec<(Value, Value)>), + Tag(u64, Box), + Bool(bool), + Null, + Undefined, + Simple(u8), + Float(f64), + /// The `0xFF` break stop-code; only valid inside an indefinite-length item. + Break, +} + +impl Value { + /// The item as an integer, for the `[selector, addr, value]` shapes. + pub fn as_i128(&self) -> Option { + match self { + Value::Uint(u) => Some(*u as i128), + Value::Neg(n) => Some(*n), + _ => None, + } + } + + /// The elements, if this is an array (unwrapping one layer of tag first). + pub fn as_array(&self) -> Option<&[Value]> { + match self { + Value::Array(items) => Some(items), + Value::Tag(_, inner) => inner.as_array(), + _ => None, + } + } +} + +/// Why a parse did not yield an item. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Error { + /// Ran out of bytes mid-item — wait for more of the stream. + Incomplete, + /// Not valid CBOR (reserved additional-info, bad UTF-8, nesting too deep). + Invalid, +} + +/// Nesting limit — guards the recursive descent against hostile/desynced input. +const MAX_DEPTH: usize = 32; + +/// Heads that open a text string (major type 3), definite or indefinite. +fn is_text_head(b: u8) -> bool { + b >> 5 == 3 +} + +/// A streaming CBOR reader: feed it TCP chunks, take whole items back. +/// +/// The channel pads between top-level items with runs of the filler byte +/// [`generated::CBOR_FILLER_BYTE`] (`0xC0`) — a `tag(0)` head with no content, +/// which is not well-formed CBOR on its own. Parsed naively it swallows the +/// following item, so the decoder skips it. A genuine `tag(0)` is an RFC 8949 +/// date/time whose content must be a text string, so a `0xC0` is treated as +/// filler only when the next head is *not* a text head, leaving real datetimes +/// intact. +#[derive(Debug, Default)] +pub struct Decoder { + buf: Vec, + filler: usize, +} + +impl Decoder { + pub fn new() -> Self { + Self::default() + } + + /// Feed raw stream bytes; return every item completed by this input. + /// + /// A byte that cannot start a valid item is dropped to resync (the same + /// strategy [`crate::midi3::Unframer`] uses), so a mid-stream join or a + /// wrong-protocol guess degrades to noise rather than a permanent stall. + pub fn push(&mut self, data: &[u8]) -> Vec { + self.buf.extend_from_slice(data); + let mut out = Vec::new(); + let mut off = 0usize; + // `skip_filler` returning None means a trailing filler byte we cannot + // classify until more bytes arrive — stop and keep it buffered. + while let Some(next) = self.skip_filler(off) { + off = next; + match parse_item(&self.buf[off..], 0) { + Ok((value, used)) => { + off += used; + out.push(value); + } + Err(Error::Incomplete) => break, + Err(Error::Invalid) => off += 1, // resync + } + } + self.buf.drain(..off); + out + } + + /// Bytes buffered but not yet forming a complete item. + pub fn pending(&self) -> usize { + self.buf.len() + } + + /// How many filler bytes have been skipped over this stream's life. + pub fn filler_bytes(&self) -> usize { + self.filler + } + + /// Step `off` past any inter-item filler. `None` means the buffer ends on a + /// filler byte whose role cannot be decided until the next byte arrives. + fn skip_filler(&mut self, mut off: usize) -> Option { + while self.buf.get(off) == Some(&generated::CBOR_FILLER_BYTE) { + match self.buf.get(off + 1) { + Some(&b) if is_text_head(b) => return Some(off), // real datetime tag + Some(_) => { + off += 1; + self.filler += 1; + } + None => return None, + } + } + Some(off) + } +} + +/// Parse one item from the front of `b`. Returns the item and bytes consumed. +fn parse_item(b: &[u8], depth: usize) -> Result<(Value, usize), Error> { + if depth > MAX_DEPTH { + return Err(Error::Invalid); + } + let head = *b.first().ok_or(Error::Incomplete)?; + let major = head >> 5; + let ai = head & 0x1F; + + // Additional info 31 = indefinite length (or the break stop-code). + if ai == 31 { + return match major { + 2 => parse_indefinite_chunks(b, depth, true), + 3 => parse_indefinite_chunks(b, depth, false), + 4 => parse_indefinite_array(b, depth), + 5 => parse_indefinite_map(b, depth), + 7 => Ok((Value::Break, 1)), + _ => Err(Error::Invalid), + }; + } + + let (arg, head_len) = parse_argument(b, ai)?; + let rest = &b[head_len..]; + + match major { + 0 => Ok((Value::Uint(arg), head_len)), + 1 => Ok((Value::Neg(-1 - arg as i128), head_len)), + 2 | 3 => { + let n = usize::try_from(arg).map_err(|_| Error::Invalid)?; + let bytes = rest.get(..n).ok_or(Error::Incomplete)?; + let value = if major == 2 { + Value::Bytes(bytes.to_vec()) + } else { + Value::Text( + std::str::from_utf8(bytes) + .map_err(|_| Error::Invalid)? + .to_string(), + ) + }; + Ok((value, head_len + n)) + } + 4 => { + let n = usize::try_from(arg).map_err(|_| Error::Invalid)?; + let mut items = Vec::with_capacity(n.min(64)); + let mut off = head_len; + for _ in 0..n { + let (v, used) = parse_item(&b[off..], depth + 1)?; + off += used; + items.push(v); + } + Ok((Value::Array(items), off)) + } + 5 => { + let n = usize::try_from(arg).map_err(|_| Error::Invalid)?; + let mut pairs = Vec::with_capacity(n.min(64)); + let mut off = head_len; + for _ in 0..n { + let (k, used) = parse_item(&b[off..], depth + 1)?; + off += used; + let (v, used) = parse_item(&b[off..], depth + 1)?; + off += used; + pairs.push((k, v)); + } + Ok((Value::Map(pairs), off)) + } + 6 => { + let (inner, used) = parse_item(rest, depth + 1)?; + Ok((Value::Tag(arg, Box::new(inner)), head_len + used)) + } + 7 => parse_simple(ai, arg, head_len), + _ => Err(Error::Invalid), + } +} + +/// Read the head byte's argument. Returns `(value, total head length)`. +fn parse_argument(b: &[u8], ai: u8) -> Result<(u64, usize), Error> { + let width = match ai { + 0..=23 => return Ok((ai as u64, 1)), + 24 => 1, + 25 => 2, + 26 => 4, + 27 => 8, + _ => return Err(Error::Invalid), // 28..=30 are reserved + }; + let bytes = b.get(1..1 + width).ok_or(Error::Incomplete)?; + let arg = bytes.iter().fold(0u64, |acc, &x| (acc << 8) | x as u64); + Ok((arg, 1 + width)) +} + +/// Major type 7: booleans, null, undefined, simple values and floats. +fn parse_simple(ai: u8, arg: u64, head_len: usize) -> Result<(Value, usize), Error> { + let value = match ai { + 20 => Value::Bool(false), + 21 => Value::Bool(true), + 22 => Value::Null, + 23 => Value::Undefined, + 0..=19 | 24 => Value::Simple(arg as u8), + 25 => Value::Float(f16_to_f64(arg as u16)), + 26 => Value::Float(f32::from_bits(arg as u32) as f64), + 27 => Value::Float(f64::from_bits(arg)), + _ => return Err(Error::Invalid), + }; + Ok((value, head_len)) +} + +/// Indefinite-length byte/text string: definite chunks until the break code. +fn parse_indefinite_chunks( + b: &[u8], + depth: usize, + bytes_mode: bool, +) -> Result<(Value, usize), Error> { + let mut off = 1; + let mut acc: Vec = Vec::new(); + loop { + if *b.get(off).ok_or(Error::Incomplete)? == 0xFF { + off += 1; + break; + } + match parse_item(&b[off..], depth + 1)? { + (Value::Bytes(chunk), used) if bytes_mode => { + acc.extend_from_slice(&chunk); + off += used; + } + (Value::Text(chunk), used) if !bytes_mode => { + acc.extend_from_slice(chunk.as_bytes()); + off += used; + } + _ => return Err(Error::Invalid), // chunks must match the outer type + } + } + let value = if bytes_mode { + Value::Bytes(acc) + } else { + Value::Text(String::from_utf8(acc).map_err(|_| Error::Invalid)?) + }; + Ok((value, off)) +} + +fn parse_indefinite_array(b: &[u8], depth: usize) -> Result<(Value, usize), Error> { + let mut off = 1; + let mut items = Vec::new(); + loop { + if *b.get(off).ok_or(Error::Incomplete)? == 0xFF { + off += 1; + break; + } + let (v, used) = parse_item(&b[off..], depth + 1)?; + off += used; + items.push(v); + } + Ok((Value::Array(items), off)) +} + +fn parse_indefinite_map(b: &[u8], depth: usize) -> Result<(Value, usize), Error> { + let mut off = 1; + let mut pairs = Vec::new(); + loop { + if *b.get(off).ok_or(Error::Incomplete)? == 0xFF { + off += 1; + break; + } + let (k, used) = parse_item(&b[off..], depth + 1)?; + off += used; + let (v, used) = parse_item(&b[off..], depth + 1)?; + off += used; + pairs.push((k, v)); + } + Ok((Value::Map(pairs), off)) +} + +/// IEEE-754 half precision → f64 (CBOR additional info 25). +fn f16_to_f64(bits: u16) -> f64 { + let sign = if bits & 0x8000 != 0 { -1.0 } else { 1.0 }; + let exp = ((bits >> 10) & 0x1F) as i32; + let frac = (bits & 0x03FF) as f64; + let mag = match exp { + 0 => frac * 2f64.powi(-24), // subnormal + 31 if frac == 0.0 => f64::INFINITY, + 31 => f64::NAN, + _ => (frac / 1024.0 + 1.0) * 2f64.powi(exp - 15), + }; + sign * mag +} + +// -------------------------------------------------------------------------- +// Encoding +// -------------------------------------------------------------------------- + +/// Write a CBOR head: the 3-bit major type plus the shortest argument encoding +/// that fits, which is what the device itself emits. +fn write_head(major: u8, arg: u64, out: &mut Vec) { + let m = major << 5; + match arg { + 0..=23 => out.push(m | arg as u8), + 24..=0xFF => out.extend_from_slice(&[m | 24, arg as u8]), + 0x100..=0xFFFF => { + out.push(m | 25); + out.extend_from_slice(&(arg as u16).to_be_bytes()); + } + 0x1_0000..=0xFFFF_FFFF => { + out.push(m | 26); + out.extend_from_slice(&(arg as u32).to_be_bytes()); + } + _ => { + out.push(m | 27); + out.extend_from_slice(&arg.to_be_bytes()); + } + } +} + +/// Encode a value as CBOR, appending to `out`. +/// +/// [`Value::Break`] encodes as the bare `0xFF` stop code, which is only +/// well-formed inside an indefinite-length item; this module never produces one, +/// so encoding a stray `Break` yields bytes the device would reject. +pub fn encode(v: &Value, out: &mut Vec) { + match v { + Value::Uint(u) => write_head(0, *u, out), + // Major type 1 encodes -1-n as the argument n. + Value::Neg(n) => write_head(1, (-1 - *n) as u64, out), + Value::Bytes(b) => { + write_head(2, b.len() as u64, out); + out.extend_from_slice(b); + } + Value::Text(t) => { + write_head(3, t.len() as u64, out); + out.extend_from_slice(t.as_bytes()); + } + Value::Array(items) => { + write_head(4, items.len() as u64, out); + items.iter().for_each(|i| encode(i, out)); + } + Value::Map(pairs) => { + write_head(5, pairs.len() as u64, out); + for (k, val) in pairs { + encode(k, out); + encode(val, out); + } + } + Value::Tag(t, inner) => { + write_head(6, *t, out); + encode(inner, out); + } + Value::Bool(false) => out.push(0xF4), + Value::Bool(true) => out.push(0xF5), + Value::Null => out.push(0xF6), + Value::Undefined => out.push(0xF7), + Value::Simple(s) => write_head(7, *s as u64, out), + Value::Float(f) => { + out.push(0xFB); + out.extend_from_slice(&f.to_bits().to_be_bytes()); + } + Value::Break => out.push(0xFF), + } +} + +/// Encode a value as a fresh byte vector. +pub fn to_vec(v: &Value) -> Vec { + let mut out = Vec::new(); + encode(v, &mut out); + out +} + +/// An integer as the right [`Value`] variant for its sign. +fn int(n: i64) -> Value { + if n < 0 { + Value::Neg(n as i128) + } else { + Value::Uint(n as u64) + } +} + +/// Build a single-parameter write, `tag(1)([1, addr, value])` — the shape the +/// channel uses to set a parameter (`docs/06`). +/// +/// This is a **write**: the device applies it and rebroadcasts it to every other +/// open session. The one this library sends — +/// `param_write(`[`generated::STATE_DUMP_TRIGGER_ADDRESS`]`, 1)` — is +/// non-mutating and asks the device for its full state. +pub fn param_write(addr: u32, value: i64) -> Value { + Value::Tag( + generated::CBOR_ITEM_TAG, + Box::new(Value::Array(vec![ + Value::Uint(generated::CBOR_SELECTOR_SINGLE as u64), + Value::Uint(addr as u64), + int(value), + ])), + ) +} + +/// The item that asks the device for its full parameter state. +pub fn state_dump_request() -> Value { + param_write( + generated::STATE_DUMP_TRIGGER_ADDRESS, + generated::STATE_DUMP_TRIGGER_VALUE, + ) +} + +// -------------------------------------------------------------------------- +// Snapshot +// -------------------------------------------------------------------------- + +/// Is `addr` one whose string value is a device secret (WiFi credentials)? +/// The state dump volunteers these in the clear; a reader must never surface +/// them. See [`generated::SENSITIVE_ADDRESSES`]. +pub fn is_sensitive(addr: u32) -> bool { + generated::SENSITIVE_ADDRESSES.contains(&addr) +} + +/// The device's current position and the names carried alongside it, read out of +/// a state dump. Both indices are 0-based; either is `None` if the dump did not +/// carry it. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct StateSnapshot { + /// Current bank, 0-based ([`generated::CURRENT_BANK_ADDRESS`]). + pub current_bank: Option, + /// Current rig slot within the bank, 0-based + /// ([`generated::CURRENT_RIG_SLOT_ADDRESS`]). + pub current_rig_slot: Option, + /// String parameters the dump carried, by address, with any sensitive value + /// redacted. In document order; useful for the current rig name (address 1) + /// and the bank name. + pub strings: Vec<(u32, String)>, +} + +impl StateSnapshot { + /// True once both indices are known — the reader can stop early. + pub fn is_complete(&self) -> bool { + self.current_bank.is_some() && self.current_rig_slot.is_some() + } + + /// The string parameter at `addr`, if the dump carried one. + pub fn string(&self, addr: u32) -> Option<&str> { + self.strings + .iter() + .find(|(a, _)| *a == addr) + .map(|(_, s)| s.as_str()) + } +} + +/// Record the value at one address into the snapshot's indices. +fn note_index(snap: &mut StateSnapshot, addr: i128, value: i128) { + let v = u16::try_from(value).ok(); + if addr == generated::CURRENT_BANK_ADDRESS as i128 { + snap.current_bank = v; + } else if addr == generated::CURRENT_RIG_SLOT_ADDRESS as i128 { + snap.current_rig_slot = v; + } +} + +/// Read the current bank, rig slot and string parameters out of decoded dump +/// items. +/// +/// Scans the two index-bearing shapes: a single `[1, addr, value]`, and a +/// consecutive-run `[2, base, v0, v1, …]` where the whole run is walked because +/// the target address can fall anywhere inside it (at session open the position +/// arrives inside one run). A leading negative source-flag word, if present, is +/// skipped exactly as [`param_write`] never emits one. +pub fn extract_snapshot(items: &[Value]) -> StateSnapshot { + let mut snap = StateSnapshot::default(); + for item in items { + let Some(fields) = item.as_array() else { + continue; + }; + // Skip a leading negative source-flags word. + let rest = match fields.first().and_then(Value::as_i128) { + Some(n) if n < 0 => &fields[1..], + _ => fields, + }; + let Some(selector) = rest.first().and_then(Value::as_i128) else { + continue; + }; + let addr = rest.get(1).and_then(Value::as_i128); + if selector == generated::CBOR_SELECTOR_SINGLE as i128 { + if let (Some(a), Some(v)) = (addr, rest.get(2).and_then(Value::as_i128)) { + note_index(&mut snap, a, v); + } + } else if selector == generated::CBOR_SELECTOR_MULTI as i128 { + if let Some(base) = addr { + for (i, v) in rest[2..].iter().enumerate() { + if let Some(v) = v.as_i128() { + note_index(&mut snap, base + i as i128, v); + } + } + } + } else if selector == generated::CBOR_SELECTOR_STRING as i128 { + if let (Some(a), Some(Value::Text(t))) = (addr, rest.get(2)) { + if !t.is_empty() { + let a = a as u32; + let text = if is_sensitive(a) { + generated::REDACTED_PLACEHOLDER.to_string() + } else { + t.clone() + }; + snap.strings.push((a, text)); + } + } + } + } + snap +} + +impl StateSnapshot { + /// Default time to keep reading the dump before giving up on the indices. + pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(3); + + /// Open a fresh CBOR session to `ip`, trigger the state dump, and read back + /// the current bank and rig slot. + /// + /// This opens its **own** short-lived connection, independent of any + /// [`DeviceModel`](crate::model), and closes it (by drop) on return. The + /// device crashes under connection churn, so run this sequentially — before, + /// not concurrently with, a streaming session — and sparingly. Returns as + /// soon as both indices are known or the default timeout elapses. + pub async fn fetch(ip: Ipv4Addr) -> Result { + Self::fetch_with(ip, Self::DEFAULT_TIMEOUT).await + } + + /// [`fetch`](Self::fetch) with an explicit read timeout. + pub async fn fetch_with( + ip: Ipv4Addr, + timeout: Duration, + ) -> Result { + let idle = Duration::from_millis(30); + let mut session = Session::connect(ip).await?; + let outcome = session.handshake(&[PROTOCOL_CBOR_CONTROL], idle).await?; + session.write_session_preamble().await?; + + let mut decoder = Decoder::new(); + let mut items = decoder.push(outcome.response_tail()); + + // Ask for the full state, then read until both indices land or the + // deadline passes. + session.write_all(&to_vec(&state_dump_request())).await?; + + let deadline = tokio::time::Instant::now() + timeout; + loop { + if extract_snapshot(&items).is_complete() { + break; + } + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match session.read_once(idle.min(remaining), 64 * 1024).await { + Ok(chunk) if chunk.is_empty() => {} + Ok(chunk) => items.extend(decoder.push(&chunk)), + Err(SessionError::Closed) => break, + Err(e) => return Err(e), + } + } + Ok(extract_snapshot(&items)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_with_minimal_length_heads() { + // uint16 address, small value. + assert_eq!( + to_vec(¶m_write(15953, 0)), + vec![0xc1, 0x83, 0x01, 0x19, 0x3e, 0x51, 0x00] + ); + // uint32 address (the extended range). + assert_eq!( + to_vec(¶m_write(102405, 19)), + vec![0xc1, 0x83, 0x01, 0x1a, 0x00, 0x01, 0x90, 0x05, 0x13] + ); + } + + #[test] + fn encodes_the_state_dump_request() { + // tag(1)([1, 102528, 1]) — the write that asks for the full state. + assert_eq!( + to_vec(&state_dump_request()), + vec![0xc1, 0x83, 0x01, 0x1a, 0x00, 0x01, 0x90, 0x80, 0x01] + ); + } + + #[test] + fn round_trips_through_the_decoder() { + for item in [ + param_write(102528, 1), + param_write(0, 0), + param_write(16383, -1), + ] { + let mut d = Decoder::new(); + let out = d.push(&to_vec(&item)); + assert_eq!(out, vec![item]); + assert_eq!(d.pending(), 0); + } + } + + #[test] + fn decoder_skips_inter_item_filler() { + let mut bytes = vec![generated::CBOR_FILLER_BYTE, generated::CBOR_FILLER_BYTE]; + bytes.extend(to_vec(¶m_write(1412, 8629))); + let mut d = Decoder::new(); + let out = d.push(&bytes); + assert_eq!(out, vec![param_write(1412, 8629)]); + assert_eq!(d.filler_bytes(), 2); + } + + /// A multi-parameter run `[2, 100700, v0, v1, v2]` carries the position: + /// 100700, then bank at 100701 and slot at 100702. + #[test] + fn extracts_position_from_a_multi_run() { + let run = Value::Tag( + 1, + Box::new(Value::Array(vec![ + Value::Uint(2), + Value::Uint(100_700), + Value::Uint(0), + Value::Uint(1), + Value::Uint(2), + ])), + ); + let snap = extract_snapshot(&[run]); + assert_eq!(snap.current_bank, Some(1)); + assert_eq!(snap.current_rig_slot, Some(2)); + assert!(snap.is_complete()); + } + + #[test] + fn extracts_position_from_single_items() { + let items = vec![param_write(100_701, 3), param_write(100_702, 4)]; + let snap = extract_snapshot(&items); + assert_eq!(snap.current_bank, Some(3)); + assert_eq!(snap.current_rig_slot, Some(4)); + } + + #[test] + fn collects_strings_and_redacts_secrets() { + let name = Value::Tag( + 1, + Box::new(Value::Array(vec![ + Value::Uint(4), + Value::Uint(1), + Value::Text("Maz 18 Pushed".into()), + ])), + ); + let secret = Value::Tag( + 1, + Box::new(Value::Array(vec![ + Value::Uint(4), + Value::Uint(generated::SENSITIVE_ADDRESSES[0] as u64), + Value::Text("hunter2".into()), + ])), + ); + let snap = extract_snapshot(&[name, secret]); + assert_eq!(snap.string(1), Some("Maz 18 Pushed")); + assert_eq!( + snap.string(generated::SENSITIVE_ADDRESSES[0]), + Some(generated::REDACTED_PLACEHOLDER) + ); + } +} diff --git a/rust/src/session.rs b/rust/src/session.rs index 3643e2c..cda662d 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -33,10 +33,23 @@ pub const PROTOCOL_MIDI3_STREAM: &str = generated::PROTOCOL_MIDI3_STREAM; /// Request/response protocol identifier: accepts the handshake but pushes nothing. pub const PROTOCOL_REQUEST_RESPONSE: &str = generated::PROTOCOL_REQUEST_RESPONSE; +/// The device's native CBOR control channel — the state-dump snapshot route +/// ([`crate::cbor`]). Completes the same handshake and preamble as the MIDI3 +/// stream, then speaks CBOR rather than MIDI3 frames. +pub const PROTOCOL_CBOR_CONTROL: &str = generated::PROTOCOL_CBOR_CONTROL; + /// 8 zero bytes the client writes to open the stream. pub const SESSION_PREAMBLE: [u8; generated::SESSION_PREAMBLE_LEN] = [0u8; generated::SESSION_PREAMBLE_LEN]; +/// Minimum quiet gap between one session closing and the next opening. The +/// device refuses to greet — or resets — a session opened too soon after a prior +/// socket closed, so an orchestrator that opens more than one session (the CBOR +/// [`StateSnapshot::fetch`](crate::cbor::StateSnapshot::fetch) then a MIDI3 +/// [`DeviceModel`](crate::model::DeviceModel)) must space them by at least this +/// and never overlap them. See `docs/06`. +pub const CONNECTION_COOLDOWN: Duration = Duration::from_millis(generated::CONNECTION_COOLDOWN_MS); + /// An open TCP session with a Profiler. pub struct Session { stream: TcpStream, diff --git a/spec/vectors/cbor.json b/spec/vectors/cbor.json new file mode 100644 index 0000000..8d21784 --- /dev/null +++ b/spec/vectors/cbor.json @@ -0,0 +1,55 @@ +{ + "description": "The native CBOR control channel: the state-dump write, and reading the current bank/rig position out of a decoded dump.", + "param_write": [ + { + "addr": 15953, + "value": 0, + "hex": "c18301193e5100" + }, + { + "addr": 102405, + "value": 19, + "hex": "c183011a0001900513" + }, + { + "addr": 102528, + "value": 1, + "hex": "c183011a0001908001" + } + ], + "state_dump_request": { + "hex": "c183011a0001908001" + }, + "extract_snapshot": [ + { + "name": "position inside one consecutive multi-run, after inter-item filler", + "stream_hex": "c0c0c185021a0001895c000102", + "expect": { + "current_bank": 1, + "current_rig_slot": 2, + "strings": [] + } + }, + { + "name": "position from single items, plus a rig name and a redacted secret", + "stream_hex": "c183011a0001895d03c183011a0001895e04c18304016d4d617a20313820507573686564c183041a00030d4866736563726574", + "expect": { + "current_bank": 3, + "current_rig_slot": 4, + "strings": [ + { "addr": 1, "text": "Maz 18 Pushed" }, + { "addr": 200008, "text": "[redacted]" } + ] + } + }, + { + "name": "an empty stream yields no position", + "stream_hex": "", + "expect": { + "current_bank": null, + "current_rig_slot": null, + "strings": [] + } + } + ] +} diff --git a/swift/Sources/LibKP/Cbor.swift b/swift/Sources/LibKP/Cbor.swift new file mode 100644 index 0000000..c14cce4 --- /dev/null +++ b/swift/Sources/LibKP/Cbor.swift @@ -0,0 +1,581 @@ +import Foundation + +// MARK: - Value + +/// A decoded CBOR (RFC 8949) data item. +/// +/// The device's native control channel (``Session/protocolCborControl``) speaks +/// CBOR rather than MIDI3 frames — see `docs/06`. This is one item off that +/// stream. `neg` holds the *actual* negative value (CBOR encodes `-1 - n`); the +/// values seen on this channel fit comfortably in `Int64`. +public indirect enum CBORValue: Sendable, Equatable { + case uint(UInt64) + case neg(Int64) + case bytes([UInt8]) + case text(String) + case array([CBORValue]) + case map([Pair]) + case tag(UInt64, CBORValue) + case bool(Bool) + case null + case undefined + case simple(UInt8) + case float(Double) + /// The `0xFF` break stop-code; only valid inside an indefinite-length item. + case breakStop + + /// One CBOR map entry (a nominal type so ``CBORValue`` stays `Equatable`). + public struct Pair: Sendable, Equatable { + public let key: CBORValue + public let value: CBORValue + public init(_ key: CBORValue, _ value: CBORValue) { + self.key = key + self.value = value + } + } + + /// The item as an integer, for the `[selector, addr, value]` shapes. + public var asInt: Int64? { + switch self { + case .uint(let u): return Int64(exactly: u) + case .neg(let n): return n + default: return nil + } + } + + /// The elements, if this is an array (unwrapping one layer of tag first). + public var asArray: [CBORValue]? { + switch self { + case .array(let items): return items + case .tag(_, let inner): return inner.asArray + default: return nil + } + } +} + +// MARK: - Decoder + +/// A streaming CBOR reader: feed it TCP chunks, take whole items back. +/// +/// The channel pads between top-level items with runs of the filler byte +/// ``Generated/cborFillerByte`` (`0xC0`) — a `tag(0)` head with no content, which +/// is not well-formed CBOR on its own. Parsed naively it swallows the following +/// item, so the decoder skips it. A genuine `tag(0)` is an RFC 8949 date/time +/// whose content must be a text string, so a `0xC0` is treated as filler only +/// when the next head is *not* a text head, leaving real datetimes intact. +public struct CBORDecoder: Sendable { + private var buffer: [UInt8] = [] + private var fillerCount = 0 + + public init() {} + + /// Bytes buffered but not yet forming a complete item. + public var pending: Int { buffer.count } + + /// How many filler bytes have been skipped over this stream's life. + public var fillerBytes: Int { fillerCount } + + /// Feed raw stream bytes; return every item completed by this input. + /// + /// A byte that cannot start a valid item is dropped to resync (the same + /// strategy ``Midi3/Unframer`` uses), so a mid-stream join or a wrong-protocol + /// guess degrades to noise rather than a permanent stall. + public mutating func push(_ data: [UInt8]) -> [CBORValue] { + buffer.append(contentsOf: data) + var out: [CBORValue] = [] + var off = 0 + // `skipFiller` returning nil means a trailing filler byte we cannot + // classify until more bytes arrive — stop and keep it buffered. + loop: while let next = skipFiller(from: off) { + off = next + switch parseItem(buffer, off, 0) { + case .success(let value, let used): + off += used + out.append(value) + case .failure(.incomplete): + break loop + case .failure(.invalid): + off += 1 // resync + } + } + if off > 0 { buffer.removeFirst(off) } + return out + } + + /// Step past any inter-item filler. `nil` means the buffer ends on a filler + /// byte whose role cannot be decided until the next byte arrives. + private mutating func skipFiller(from start: Int) -> Int? { + var off = start + while off < buffer.count && buffer[off] == Generated.cborFillerByte { + guard off + 1 < buffer.count else { return nil } + if isTextHead(buffer[off + 1]) { return off } // real datetime tag + off += 1 + fillerCount += 1 + } + return off + } +} + +/// Heads that open a text string (major type 3), definite or indefinite. +private func isTextHead(_ b: UInt8) -> Bool { b >> 5 == 3 } + +/// Nesting limit — guards the recursive descent against hostile/desynced input. +private let maxDepth = 32 + +private enum CBORParseError: Error { + /// Ran out of bytes mid-item — wait for more of the stream. + case incomplete + /// Not valid CBOR (reserved additional-info, bad UTF-8, nesting too deep). + case invalid +} + +/// The outcome of parsing one item: the item and bytes consumed, or why not. +private enum ParseResult { + case success(CBORValue, Int) + case failure(CBORParseError) +} + +/// Parse one item from `b` starting at `off`. `used` is relative to `off`. +private func parseItem(_ b: [UInt8], _ off: Int, _ depth: Int) -> ParseResult { + if depth > maxDepth { return .failure(.invalid) } + guard off < b.count else { return .failure(.incomplete) } + let head = b[off] + let major = head >> 5 + let ai = head & 0x1F + + // Additional info 31 = indefinite length (or the break stop-code). + if ai == 31 { + switch major { + case 2: return parseIndefiniteChunks(b, off, depth, bytesMode: true) + case 3: return parseIndefiniteChunks(b, off, depth, bytesMode: false) + case 4: return parseIndefiniteArray(b, off, depth) + case 5: return parseIndefiniteMap(b, off, depth) + case 7: return .success(.breakStop, 1) + default: return .failure(.invalid) + } + } + + let arg: UInt64 + let headLen: Int + switch parseArgument(b, off, ai) { + case .success(let parsed): arg = parsed.0; headLen = parsed.1 + case .failure(let e): return .failure(e) + } + let rest = off + headLen + + switch major { + case 0: + return .success(.uint(arg), headLen) + case 1: + return .success(.neg(-1 - Int64(bitPattern: arg)), headLen) + case 2, 3: + guard let n = Int(exactly: arg) else { return .failure(.invalid) } + guard rest + n <= b.count else { return .failure(.incomplete) } + let slice = Array(b[rest..<(rest + n)]) + if major == 2 { + return .success(.bytes(slice), headLen + n) + } + guard let text = String(bytes: slice, encoding: .utf8) else { return .failure(.invalid) } + return .success(.text(text), headLen + n) + case 4: + guard let n = Int(exactly: arg) else { return .failure(.invalid) } + var items: [CBORValue] = [] + items.reserveCapacity(min(n, 64)) + var cursor = off + headLen + for _ in 0.. Result<(UInt64, Int), CBORParseError> { + let width: Int + switch ai { + case 0...23: return .success((UInt64(ai), 1)) + case 24: width = 1 + case 25: width = 2 + case 26: width = 4 + case 27: width = 8 + default: return .failure(.invalid) // 28...30 are reserved + } + guard off + 1 + width <= b.count else { return .failure(.incomplete) } + var arg: UInt64 = 0 + for i in 0.. ParseResult { + switch ai { + case 20: return .success(.bool(false), headLen) + case 21: return .success(.bool(true), headLen) + case 22: return .success(.null, headLen) + case 23: return .success(.undefined, headLen) + case 0...19, 24: return .success(.simple(UInt8(truncatingIfNeeded: arg)), headLen) + case 25: return .success(.float(f16ToDouble(UInt16(truncatingIfNeeded: arg))), headLen) + case 26: + return .success(.float(Double(Float(bitPattern: UInt32(truncatingIfNeeded: arg)))), headLen) + case 27: return .success(.float(Double(bitPattern: arg)), headLen) + default: return .failure(.invalid) + } +} + +/// Indefinite-length byte/text string: definite chunks until the break code. +private func parseIndefiniteChunks( + _ b: [UInt8], _ off: Int, _ depth: Int, bytesMode: Bool +) -> ParseResult { + var cursor = off + 1 + var acc: [UInt8] = [] + while true { + guard cursor < b.count else { return .failure(.incomplete) } + if b[cursor] == 0xFF { + cursor += 1 + break + } + switch parseItem(b, cursor, depth + 1) { + case .success(.bytes(let chunk), let used) where bytesMode: + acc.append(contentsOf: chunk) + cursor += used + case .success(.text(let chunk), let used) where !bytesMode: + acc.append(contentsOf: Array(chunk.utf8)) + cursor += used + case .success: return .failure(.invalid) // chunks must match the outer type + case .failure(let e): return .failure(e) + } + } + if bytesMode { return .success(.bytes(acc), cursor - off) } + guard let text = String(bytes: acc, encoding: .utf8) else { return .failure(.invalid) } + return .success(.text(text), cursor - off) +} + +private func parseIndefiniteArray(_ b: [UInt8], _ off: Int, _ depth: Int) -> ParseResult { + var cursor = off + 1 + var items: [CBORValue] = [] + while true { + guard cursor < b.count else { return .failure(.incomplete) } + if b[cursor] == 0xFF { + cursor += 1 + break + } + switch parseItem(b, cursor, depth + 1) { + case .success(let v, let used): cursor += used; items.append(v) + case .failure(let e): return .failure(e) + } + } + return .success(.array(items), cursor - off) +} + +private func parseIndefiniteMap(_ b: [UInt8], _ off: Int, _ depth: Int) -> ParseResult { + var cursor = off + 1 + var pairs: [CBORValue.Pair] = [] + while true { + guard cursor < b.count else { return .failure(.incomplete) } + if b[cursor] == 0xFF { + cursor += 1 + break + } + let key: CBORValue + switch parseItem(b, cursor, depth + 1) { + case .success(let v, let used): cursor += used; key = v + case .failure(let e): return .failure(e) + } + switch parseItem(b, cursor, depth + 1) { + case .success(let v, let used): cursor += used; pairs.append(.init(key, v)) + case .failure(let e): return .failure(e) + } + } + return .success(.map(pairs), cursor - off) +} + +/// IEEE-754 half precision → Double (CBOR additional info 25). +private func f16ToDouble(_ bits: UInt16) -> Double { + let sign = (bits & 0x8000) != 0 ? -1.0 : 1.0 + let exp = Int((bits >> 10) & 0x1F) + let frac = Double(bits & 0x03FF) + let mag: Double + switch exp { + case 0: mag = frac * pow(2.0, -24) // subnormal + case 31 where frac == 0: mag = .infinity + case 31: mag = .nan + default: mag = (frac / 1024.0 + 1.0) * pow(2.0, Double(exp - 15)) + } + return sign * mag +} + +// MARK: - Encode / snapshot + +/// The reader and writer for the device's native CBOR channel, and the +/// state-dump snapshot that reads the current bank and rig slot out of it. +public enum Cbor { + /// Encode a value as a fresh byte array, using minimal-length integer heads — + /// the shortest head that fits each argument, as the device itself emits. + public static func encode(_ value: CBORValue) -> [UInt8] { + var out: [UInt8] = [] + encode(value, into: &out) + return out + } + + private static func encode(_ value: CBORValue, into out: inout [UInt8]) { + switch value { + case .uint(let u): writeHead(0, u, &out) + case .neg(let n): writeHead(1, UInt64(-1 - n), &out) // CBOR encodes -1-n + case .bytes(let b): + writeHead(2, UInt64(b.count), &out) + out.append(contentsOf: b) + case .text(let t): + let u = Array(t.utf8) + writeHead(3, UInt64(u.count), &out) + out.append(contentsOf: u) + case .array(let items): + writeHead(4, UInt64(items.count), &out) + for item in items { encode(item, into: &out) } + case .map(let pairs): + writeHead(5, UInt64(pairs.count), &out) + for pair in pairs { + encode(pair.key, into: &out) + encode(pair.value, into: &out) + } + case .tag(let t, let inner): + writeHead(6, t, &out) + encode(inner, into: &out) + case .bool(false): out.append(0xF4) + case .bool(true): out.append(0xF5) + case .null: out.append(0xF6) + case .undefined: out.append(0xF7) + case .simple(let s): writeHead(7, UInt64(s), &out) + case .float(let f): + out.append(0xFB) + appendBigEndian(f.bitPattern, 8, &out) + case .breakStop: out.append(0xFF) + } + } + + /// Write a CBOR head: the 3-bit major type plus the shortest argument + /// encoding that fits. + private static func writeHead(_ major: UInt8, _ arg: UInt64, _ out: inout [UInt8]) { + let m = major << 5 + switch arg { + case 0...23: out.append(m | UInt8(arg)) + case 24...0xFF: out.append(contentsOf: [m | 24, UInt8(arg)]) + case 0x100...0xFFFF: + out.append(m | 25) + appendBigEndian(arg, 2, &out) + case 0x1_0000...0xFFFF_FFFF: + out.append(m | 26) + appendBigEndian(arg, 4, &out) + default: + out.append(m | 27) + appendBigEndian(arg, 8, &out) + } + } + + private static func appendBigEndian(_ value: UInt64, _ width: Int, _ out: inout [UInt8]) { + for i in stride(from: width - 1, through: 0, by: -1) { + out.append(UInt8(truncatingIfNeeded: value >> (UInt64(i) * 8))) + } + } + + /// An integer as the right ``CBORValue`` variant for its sign. + private static func int(_ n: Int64) -> CBORValue { + n < 0 ? .neg(n) : .uint(UInt64(n)) + } + + /// Build a single-parameter write, `tag(1)([1, addr, value])` — the shape the + /// channel uses to set a parameter (`docs/06`). + /// + /// This is a **write**: the device applies it and rebroadcasts it to every + /// other open session. The one this library sends — + /// `paramWrite(``Generated/stateDumpTriggerAddress``, 1)` — is non-mutating and + /// asks the device for its full state. + public static func paramWrite(addr: UInt32, value: Int64) -> CBORValue { + .tag( + Generated.cborItemTag, + .array([.uint(UInt64(Generated.cborSelectorSingle)), .uint(UInt64(addr)), int(value)])) + } + + /// The item that asks the device for its full parameter state. + public static func stateDumpRequest() -> CBORValue { + paramWrite(addr: Generated.stateDumpTriggerAddress, value: Generated.stateDumpTriggerValue) + } + + /// Is `addr` one whose string value is a device secret (WiFi credentials)? + /// The state dump volunteers these in the clear; a reader must never surface + /// them. See ``Generated/sensitiveAddresses``. + public static func isSensitive(_ addr: UInt32) -> Bool { + Generated.sensitiveAddresses.contains(addr) + } + + /// Read the current bank, rig slot and string parameters out of decoded dump + /// items. + /// + /// Scans the two index-bearing shapes: a single `[1, addr, value]`, and a + /// consecutive-run `[2, base, v0, v1, …]` where the whole run is walked because + /// the target address can fall anywhere inside it. A leading negative + /// source-flag word, if present, is skipped. + public static func extractSnapshot(_ items: [CBORValue]) -> StateSnapshot { + var snap = StateSnapshot() + for item in items { + guard let fields = item.asArray else { continue } + // Skip a leading negative source-flags word. + let rest: [CBORValue] + if let first = fields.first?.asInt, first < 0 { + rest = Array(fields.dropFirst()) + } else { + rest = fields + } + guard let selector = rest.first?.asInt else { continue } + let addr = rest.count > 1 ? rest[1].asInt : nil + + if selector == Generated.cborSelectorSingle { + if let a = addr, rest.count > 2, let v = rest[2].asInt { + note(&snap, address: a, value: v) + } + } else if selector == Generated.cborSelectorMulti { + if let base = addr { + for (i, element) in rest.dropFirst(2).enumerated() { + if let v = element.asInt { note(&snap, address: base + Int64(i), value: v) } + } + } + } else if selector == Generated.cborSelectorString { + if let a = addr, rest.count > 2, case .text(let t) = rest[2], !t.isEmpty { + let ua = UInt32(truncatingIfNeeded: a) + let text = isSensitive(ua) ? Generated.redactedPlaceholder : t + snap.strings.append(SnapshotString(address: ua, text: text)) + } + } + } + return snap + } + + /// Record the value at one address into the snapshot's indices. + private static func note(_ snap: inout StateSnapshot, address: Int64, value: Int64) { + let v = UInt16(exactly: value) + if address == Int64(Generated.currentBankAddress) { + snap.currentBank = v + } else if address == Int64(Generated.currentRigSlotAddress) { + snap.currentRigSlot = v + } + } +} + +// MARK: - Snapshot + +/// One string parameter the state dump carried, by address, with any sensitive +/// value redacted. +public struct SnapshotString: Sendable, Equatable { + public let address: UInt32 + public let text: String + public init(address: UInt32, text: String) { + self.address = address + self.text = text + } +} + +/// The device's current position and the names carried alongside it, read out of +/// a state dump. Both indices are 0-based; either is `nil` if the dump did not +/// carry it. +public struct StateSnapshot: Sendable, Equatable { + /// Current bank, 0-based (``Generated/currentBankAddress``). + public var currentBank: UInt16? + /// Current rig slot within the bank, 0-based + /// (``Generated/currentRigSlotAddress``). + public var currentRigSlot: UInt16? + /// String parameters the dump carried, in document order, sensitive values + /// redacted. Useful for the current rig name (address 1) and the bank name. + public var strings: [SnapshotString] + + public init( + currentBank: UInt16? = nil, currentRigSlot: UInt16? = nil, + strings: [SnapshotString] = [] + ) { + self.currentBank = currentBank + self.currentRigSlot = currentRigSlot + self.strings = strings + } + + /// True once both indices are known — the reader can stop early. + public var isComplete: Bool { currentBank != nil && currentRigSlot != nil } + + /// The string parameter at `addr`, if the dump carried one. + public func string(_ addr: UInt32) -> String? { + strings.first { $0.address == addr }?.text + } + + /// Default time to keep reading the dump before giving up on the indices. + public static let defaultTimeout: TimeInterval = 3 + + /// Open a fresh CBOR session to `host`, trigger the state dump, and read back + /// the current bank and rig slot. + /// + /// This opens its **own** short-lived connection, independent of any + /// ``DeviceModel``, and closes it on return. The device is fragile under + /// connection churn, so run this sequentially — before, not concurrently with, + /// a streaming session — and sparingly. Returns as soon as both indices are + /// known or `timeout` elapses. + public static func fetch( + host: String, + port: UInt16 = Generated.port, + timeout: TimeInterval = StateSnapshot.defaultTimeout + ) async throws -> StateSnapshot { + let idle: TimeInterval = 0.03 + let session = try await Session.connect(host: host, port: port) + defer { session.close() } + + let outcome = try await session.handshake( + preferred: [Session.protocolCborControl], idle: idle) + try await session.writeSessionPreamble() + + var decoder = CBORDecoder() + var items = decoder.push(outcome.responseTail) + + // Ask for the full state, then read until both indices land or the + // deadline passes. + try await session.writeAll(Cbor.encode(Cbor.stateDumpRequest())) + + let deadline = Date().addingTimeInterval(timeout) + while !Cbor.extractSnapshot(items).isComplete { + let remaining = deadline.timeIntervalSinceNow + if remaining <= 0 { break } + do { + let chunk = try await session.readOnce(wait: min(idle, remaining)) + if !chunk.isEmpty { items.append(contentsOf: decoder.push(chunk)) } + } catch SessionError.closed { + break + } + } + return Cbor.extractSnapshot(items) + } +} diff --git a/swift/Sources/LibKP/Session.swift b/swift/Sources/LibKP/Session.swift index 81a8450..78c142c 100644 --- a/swift/Sources/LibKP/Session.swift +++ b/swift/Sources/LibKP/Session.swift @@ -44,10 +44,22 @@ public final class Session: @unchecked Sendable { /// Request/response protocol identifier: accepts the handshake but pushes /// nothing. public static let protocolRequestResponse = Generated.protocolRequestResponse + /// The device's native CBOR control channel — the state-dump snapshot route + /// (see ``Cbor`` and `docs/06`). Completes the same handshake and preamble as + /// the MIDI3 stream, then speaks CBOR rather than MIDI3 frames. + public static let protocolCborControl = Generated.protocolCborControl /// The 8 zero bytes the client writes to open the encapsulated stream. public static let sessionPreamble = [UInt8](repeating: 0, count: Generated.sessionPreambleLen) + /// Minimum quiet gap between one session closing and the next opening. The + /// device refuses to greet — or resets — a session opened too soon after a + /// prior socket closed, so an orchestrator that opens more than one session + /// (the CBOR ``StateSnapshot/fetch(host:port:timeout:)`` then a MIDI3 + /// ``DeviceModel``) must space them by at least this and never overlap them. + /// See `docs/06`. + public static let connectionCooldown = TimeInterval(Generated.connectionCooldownMs) / 1000.0 + private let connection: NWConnection private let queue = DispatchQueue(label: "com.libkp.session") private let inbox = Inbox() From e96f6fbd0c2bdd604274a6a7b2326883810a3e85 Mon Sep 17 00:00:00 2001 From: Aaron Gotwalt Date: Sat, 22 Aug 2026 16:11:20 -0700 Subject: [PATCH 2/5] Expand the control model, parameter registry and state tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related additions across the spec and all three implementations. Rig navigation. CC 48/49 and CC 50-54 are momentary buttons, and the release is not optional: a press alone loads the target rig, but without the matching release the device abandons the change and reloads the previous rig about two seconds later — which reads as the device spontaneously undoing the navigation. `Control` now renders up, down and load_slot as a press immediately followed by its release in one message. Parameter registry. Adds the volume parameters (main/monitor/headphone, plus the read-only master), the bank-preview extended-string page, and effect-type categories. Appendix B allocates type values in 16-value blocks, one per group of the device's type knob, so the registry carries those blocks as a second lookup and a UI can name a slot's family without knowing the individual type. Declaring ranges rather than rows keeps the spec small and lets the generator expand them. State tree. Tracks the device's own position reports and the bank name preview, so a client can follow rig changes made anywhere — including at the front panel — rather than only the ones it made itself. Vectors and generated output are regenerated to match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S --- codegen/README.md | 6 +- codegen/gen_vectors.py | 64 ++++- codegen/generate.py | 93 +++++++- docs/05-sysex-nrpn.md | 17 +- docs/08-control-model.md | 112 +++++++-- docs/09-parameter-registry.md | 44 +++- python/src/libkp/_generated.py | 55 ++++- python/src/libkp/control.py | 31 ++- python/src/libkp/model.py | 63 ++++- python/src/libkp/nrpn.py | 21 ++ python/src/libkp/params.py | 63 +++++ python/src/libkp/state.py | 168 ++++++++++++- python/tests/test_conformance.py | 61 ++++- python/tests/test_control.py | 11 +- python/tests/test_model.py | 74 +++++- python/tests/test_params.py | 9 + python/tests/test_state.py | 1 + rust/src/control.rs | 53 ++++- rust/src/generated.rs | 62 ++++- rust/src/model.rs | 225 +++++++++++++++++- rust/src/nrpn.rs | 35 +++ rust/src/params.rs | 104 ++++++++ rust/src/state.rs | 73 +++++- rust/tests/conformance.rs | 133 ++++++++++- spec/controls.toml | 14 +- spec/effect-types.toml | 20 ++ spec/parameters.toml | 33 +++ spec/protocol.toml | 39 ++- spec/vectors/controls.json | 12 +- spec/vectors/params.json | 66 +++++ spec/vectors/state.json | 37 +++ swift/Sources/LibKP/Control.swift | 14 +- swift/Sources/LibKP/DeviceModel.swift | 61 ++++- swift/Sources/LibKP/Generated.swift | 58 ++++- swift/Sources/LibKP/Nrpn.swift | 19 ++ swift/Sources/LibKP/Params.swift | 54 +++++ swift/Sources/LibKP/State.swift | 129 +++++++++- swift/Tests/LibKPTests/ConformanceTests.swift | 97 +++++++- swift/Tests/LibKPTests/UnitTests.swift | 148 +++++++++++- 39 files changed, 2259 insertions(+), 120 deletions(-) diff --git a/codegen/README.md b/codegen/README.md index 70d08a9..91c202e 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -37,11 +37,13 @@ Swift). Highlights: `METER_BLOCK_NUMBER`, `TEMPO_BPM_SCALE`, `MORPH_NUMBER`, `TUNER_*`, …). - **Tables**: `FUNCTION_NAMES`, `PAGE_NAMES`, `EFFECT_SLOTS`, `EFFECT_PARAMS`, `NON_EFFECT_PARAMS`, `STRING_TAGS`, `PAGE0_NUMERIC`, `EFFECT_TYPES`, - `SLOT_ENABLE_CC`, `METER_FIELDS`, and the `CC_*` control constants. + `EFFECT_CATEGORIES`, `SLOT_ENABLE_CC`, `METER_FIELDS`, and the `CC_*` control + constants. Tables are emitted as the most natural literal per language: Rust `static` slices of tuples, Python `dict`/`list`, Swift `Dictionary`/`Array` (with small -`NonEffectKey` / `MeterField` helper structs). Implementations wrap these with +`NonEffectKey` / `MeterField` / `EffectCategory` helper structs). +Implementations wrap these with thin lookup helpers (`param_name`, `effect_type_name`, …); the `params.json` vectors verify those helpers agree across languages. diff --git a/codegen/gen_vectors.py b/codegen/gen_vectors.py index 2c63734..07d541d 100644 --- a/codegen/gen_vectors.py +++ b/codegen/gen_vectors.py @@ -356,17 +356,17 @@ def poll(mac): ("tuner_mode", {"on": True}, 0, control_change(0, 31, 1)), ("tuner_mode", {"on": False}, 0, control_change(0, 31, 0)), ("toggle_all_modules", {}, 0, control_change(0, 16, 1)), - ("up", {}, 0, control_change(0, 48, 1)), - ("down", {}, 0, control_change(0, 49, 1)), + ("up", {}, 0, control_change(0, 48, 1) + control_change(0, 48, 0)), + ("down", {}, 0, control_change(0, 49, 1) + control_change(0, 49, 0)), ("bank_preselect", {"value": 3}, 0, control_change(0, 47, 3)), ("rotary_fast", {"on": True}, 0, control_change(0, 33, 1)), ("delay_infinity", {"on": True}, 0, control_change(0, 34, 1)), ("freeze", {"on": True}, 0, control_change(0, 35, 1)), ("morph_button", {"on": True}, 0, control_change(0, 80, 1)), ("morph_button", {"on": False}, 0, control_change(0, 80, 0)), - ("load_slot", {"n": 3}, 0, control_change(0, 52, 1)), - ("load_slot", {"n": 0}, 0, control_change(0, 50, 1)), - ("load_slot", {"n": 99}, 0, control_change(0, 54, 1)), + ("load_slot", {"n": 3}, 0, control_change(0, 52, 1) + control_change(0, 52, 0)), + ("load_slot", {"n": 0}, 0, control_change(0, 50, 1) + control_change(0, 50, 0)), + ("load_slot", {"n": 99}, 0, control_change(0, 54, 1) + control_change(0, 54, 0)), ("effect_button", {"n": 4}, 0, control_change(0, 78, 1)), ("effect_button", {"n": 0}, 0, control_change(0, 75, 1)), ("slot_enable", {"slot": "REV", "on": True}, 0, control_change(0, 29, 1)), @@ -380,7 +380,8 @@ def poll(mac): ] w("controls.json", { "description": "Control op -> raw MIDI bytes. Each impl maps op names to its Control API; " - "values are masked to 7 bits, load_slot clamps to 1..=5, effect_button to 1..=4.", + "values are masked to 7 bits, load_slot clamps to 1..=5, effect_button to 1..=4. " + "Momentary controls (up, down, load_slot) emit a press (value 1) followed by a release (value 0) as one 6-byte message.", "cases": [{"op": op, "params": pr, "channel": ch, "hex": hx(b)} for op, pr, ch, b in controls], }) @@ -389,6 +390,37 @@ def poll(mac): pa = tomllib.load(fh) with open(ROOT / "spec" / "effect-types.toml", "rb") as fh: et = tomllib.load(fh) + + def category_of(v: int) -> str | None: + for c in et["effect_categories"]: + if c["min"] <= v <= c["max"]: + return c["name"] + return None + + # Ground-truth category expectations, cross-checked against the spec table: + # block interiors, both sides of a block boundary, an unnamed Type value, + # the empty type, and values past the last block. + effect_category_cases = [ + {"value": 0, "name": None}, + {"value": 1, "name": "Wah"}, + {"value": 16, "name": "Wah"}, + {"value": 17, "name": "Shaper"}, + {"value": 32, "name": "Distortion"}, + {"value": 49, "name": "Dynamics"}, + {"value": 76, "name": "Modulation"}, + {"value": 89, "name": "Phaser & Flanger"}, + {"value": 112, "name": "Booster"}, + {"value": 121, "name": "Effect Loop"}, + {"value": 129, "name": "Pitch"}, + {"value": 161, "name": "Delay"}, + {"value": 179, "name": "Reverb"}, + {"value": 207, "name": "Reverb"}, + {"value": 208, "name": None}, + {"value": 300, "name": None}, + ] + for c in effect_category_cases: + assert category_of(c["value"]) == c["name"], c + w("params.json", { "description": "Offline name lookups. null means no mapping.", "param_name": [ @@ -410,6 +442,7 @@ def poll(mac): {"value": 193, "name": "Spring Reverb"}, {"value": 5, "name": None}, ], + "effect_category_name": effect_category_cases, "page_name": [ {"page": 0x7C, "name": "Realtime/Meters"}, {"page": 0x0A, "name": "Amplifier"}, @@ -477,6 +510,25 @@ def _state_cases(): cases.append({"name": "morph + tuner note + main vol", "messages": [hx(morph), hx(note), hx(mainv)], "expect": {"morph": 4096, "tuner_note": 9, "main_volume": 12000}}) + # 7. The device's position: Bank Select LSB (high 7 bits) then Program + # Change (low 7), a flat 0-based rig index that divides by BANK_SLOTS. + # Index 123 = bank 24, slot 3 — the last rig of a 25-bank device. + cases.append({"name": "rig index, single byte", + "messages": [hx(bytes([0xB0, 32, 0])), hx(bytes([0xC0, 123]))], + "expect": {"current_bank": 24, "current_rig_slot": 3, + "current_rig_index": 123}}) + # 8. An index past 127 needs the Bank Select byte: 128 * 1 + 72 = 200. + cases.append({"name": "rig index, two bytes", + "messages": [hx(bytes([0xB0, 32, 1])), hx(bytes([0xC0, 72]))], + "expect": {"current_bank": 40, "current_rig_slot": 0, + "current_rig_index": 200}}) + # 9. A Program Change with no Bank Select ahead of it is half an index, not + # a position: the trailing lone PC must leave the last one standing. + cases.append({"name": "rig index ignores an unpaired program change", + "messages": [hx(bytes([0xB0, 32, 0])), hx(bytes([0xC0, 123])), + hx(bytes([0xC0, 5]))], + "expect": {"current_bank": 24, "current_rig_slot": 3, + "current_rig_index": 123}}) return cases diff --git a/codegen/generate.py b/codegen/generate.py index 249f2a8..e32d6b3 100644 --- a/codegen/generate.py +++ b/codegen/generate.py @@ -102,6 +102,7 @@ def emit_rust(d: dict) -> str: w(f'pub const PORT: u16 = {p["transport"]["port"]};') w(f'pub const CONNECT_TIMEOUT_SECS: u64 = {p["transport"]["connect_timeout_secs"]};') w(f'pub const SOCKET_TIMEOUT_SECS: u64 = {p["transport"]["socket_timeout_secs"]};') + w(f'pub const CONNECTION_COOLDOWN_MS: u64 = {p["transport"]["connection_cooldown_ms"]};') w("") w("// Discovery") w(f'pub const DISCOVERY_HEADER: &str = {q(p["discovery"]["header"])};') @@ -123,6 +124,19 @@ def emit_rust(d: dict) -> str: w(f'pub const PROTOCOL_CBOR_CONTROL: &str = {q(p["protocols"]["cbor_control"])};') w(f'pub const PROTOCOL_RESERVED: &str = {q(p["protocols"]["reserved"])};') w("") + w("// CBOR channel") + cb = p["cbor"] + w(f'pub const CBOR_ITEM_TAG: u64 = {cb["item_tag"]};') + w(f'pub const CBOR_SELECTOR_SINGLE: i64 = {cb["selector_single"]};') + w(f'pub const CBOR_SELECTOR_MULTI: i64 = {cb["selector_multi"]};') + w(f'pub const CBOR_SELECTOR_STRING: i64 = {cb["selector_string"]};') + w(f'pub const CBOR_FILLER_BYTE: u8 = {cb["filler_byte"]:#04x};') + w(f'pub const STATE_DUMP_TRIGGER_ADDRESS: u32 = {cb["state_dump_trigger_address"]};') + w(f'pub const STATE_DUMP_TRIGGER_VALUE: i64 = {cb["state_dump_trigger_value"]};') + sens = ", ".join(str(a) for a in cb["sensitive_addresses"]) + w(f'pub const SENSITIVE_ADDRESSES: [u32; {len(cb["sensitive_addresses"])}] = [{sens}];') + w(f'pub const REDACTED_PLACEHOLDER: &str = {q(cb["redacted_placeholder"])};') + w("") w("// MIDI3 framing tags") w(f'pub const MIDI3_TAG_CONTINUATION: u8 = {p["midi3"]["tag_continuation"]:#04x};') w(f'pub const MIDI3_TAG_FINAL_1: u8 = {p["midi3"]["tag_final_1"]:#04x};') @@ -176,11 +190,18 @@ def emit_rust(d: dict) -> str: "amp_page": ("AMP_PAGE", "u8"), "amp_on_number": ("AMP_ON_NUMBER", "u8"), "gain_number": ("GAIN_NUMBER", "u8"), "system_page": ("SYSTEM_PAGE", "u8"), "main_volume_number": ("MAIN_VOLUME_NUMBER", "u8"), - "monitor_volume_number": ("MONITOR_VOLUME_NUMBER", "u8"), "page_morph": ("PAGE_MORPH", "u8"), + "headphone_volume_number": ("HEADPHONE_VOLUME_NUMBER", "u8"), + "monitor_volume_number": ("MONITOR_VOLUME_NUMBER", "u8"), + "page_bank_preview": ("PAGE_BANK_PREVIEW", "u8"), "bank_slots": ("BANK_SLOTS", "usize"), + "bank_rig_name_base": ("BANK_RIG_NAME_BASE", "u8"), + "bank_amp_name_base": ("BANK_AMP_NAME_BASE", "u8"), + "bank_cabinet_name_base": ("BANK_CABINET_NAME_BASE", "u8"), "page_morph": ("PAGE_MORPH", "u8"), "morph_number": ("MORPH_NUMBER", "u8"), "page_tuner_note": ("PAGE_TUNER_NOTE", "u8"), "tuner_note_number": ("TUNER_NOTE_NUMBER", "u8"), "tuner_in_tune_center": ("TUNER_IN_TUNE_CENTER", "u16"), "tuner_in_tune_window": ("TUNER_IN_TUNE_WINDOW", "u16"), "meter_count": ("METER_COUNT", "usize"), + "current_bank_address": ("CURRENT_BANK_ADDRESS", "u32"), + "current_rig_slot_address": ("CURRENT_RIG_SLOT_ADDRESS", "u32"), } for k, (const, ty) in wk_rust.items(): val = wk[k] @@ -227,6 +248,18 @@ def emit_rust(d: dict) -> str: sorted(d["effect-types"]["effect_types"], key=lambda e: e["value"])) w(f"pub static EFFECT_TYPES: &[(u16, &str)] = &[\n{rows},\n];") w("") + w("/// One category block of the effect Type value space: an inclusive range.") + w("#[derive(Debug, Clone, Copy, PartialEq, Eq)]") + w("pub struct EffectCategory {") + w(" pub min: u16,") + w(" pub max: u16,") + w(" pub name: &'static str,") + w("}") + rows = ",\n".join( + f" EffectCategory {{ min: {e['min']}, max: {e['max']}, name: {q(e['name'])} }}" + for e in d["effect-types"]["effect_categories"]) + w(f"pub static EFFECT_CATEGORIES: &[EffectCategory] = &[\n{rows},\n];") + w("") # CC constants + slot enable for c in d["controls"]["cc"]: w(f"pub const {cc_const_name(c['name'])}: u8 = {c['cc']};") @@ -277,6 +310,7 @@ def emit_python(d: dict) -> str: w(f'PORT = {p["transport"]["port"]}') w(f'CONNECT_TIMEOUT_SECS = {p["transport"]["connect_timeout_secs"]}') w(f'SOCKET_TIMEOUT_SECS = {p["transport"]["socket_timeout_secs"]}') + w(f'CONNECTION_COOLDOWN_MS = {p["transport"]["connection_cooldown_ms"]}') w("") w(f'DISCOVERY_HEADER = {q(p["discovery"]["header"])}') w(f'POLL_INTERVAL_MS = {p["discovery"]["poll_interval_ms"]}') @@ -295,6 +329,18 @@ def emit_python(d: dict) -> str: w(f'PROTOCOL_CBOR_CONTROL = {q(p["protocols"]["cbor_control"])}') w(f'PROTOCOL_RESERVED = {q(p["protocols"]["reserved"])}') w("") + cb = p["cbor"] + w(f'CBOR_ITEM_TAG = {cb["item_tag"]}') + w(f'CBOR_SELECTOR_SINGLE = {cb["selector_single"]}') + w(f'CBOR_SELECTOR_MULTI = {cb["selector_multi"]}') + w(f'CBOR_SELECTOR_STRING = {cb["selector_string"]}') + w(f'CBOR_FILLER_BYTE = {cb["filler_byte"]:#04x}') + w(f'STATE_DUMP_TRIGGER_ADDRESS = {cb["state_dump_trigger_address"]}') + w(f'STATE_DUMP_TRIGGER_VALUE = {cb["state_dump_trigger_value"]}') + sens = ", ".join(str(a) for a in cb["sensitive_addresses"]) + w(f'SENSITIVE_ADDRESSES = ({sens})') + w(f'REDACTED_PLACEHOLDER = {q(cb["redacted_placeholder"])}') + w("") w(f'MIDI3_TAG_CONTINUATION = {p["midi3"]["tag_continuation"]:#04x}') w(f'MIDI3_TAG_FINAL_1 = {p["midi3"]["tag_final_1"]:#04x}') w(f'MIDI3_TAG_FINAL_2 = {p["midi3"]["tag_final_2"]:#04x}') @@ -340,14 +386,20 @@ def emit_python(d: dict) -> str: "tempo_bpm_scale": "TEMPO_BPM_SCALE", "rig_volume_number": "RIG_VOLUME_NUMBER", "amp_page": "AMP_PAGE", "amp_on_number": "AMP_ON_NUMBER", "gain_number": "GAIN_NUMBER", "system_page": "SYSTEM_PAGE", "main_volume_number": "MAIN_VOLUME_NUMBER", - "monitor_volume_number": "MONITOR_VOLUME_NUMBER", "page_morph": "PAGE_MORPH", + "headphone_volume_number": "HEADPHONE_VOLUME_NUMBER", + "monitor_volume_number": "MONITOR_VOLUME_NUMBER", + "page_bank_preview": "PAGE_BANK_PREVIEW", "bank_slots": "BANK_SLOTS", + "bank_rig_name_base": "BANK_RIG_NAME_BASE", "bank_amp_name_base": "BANK_AMP_NAME_BASE", + "bank_cabinet_name_base": "BANK_CABINET_NAME_BASE", "page_morph": "PAGE_MORPH", "morph_number": "MORPH_NUMBER", "page_tuner_note": "PAGE_TUNER_NOTE", "tuner_note_number": "TUNER_NOTE_NUMBER", "tuner_in_tune_center": "TUNER_IN_TUNE_CENTER", "tuner_in_tune_window": "TUNER_IN_TUNE_WINDOW", "meter_count": "METER_COUNT", + "current_bank_address": "CURRENT_BANK_ADDRESS", + "current_rig_slot_address": "CURRENT_RIG_SLOT_ADDRESS", } for k, const in wk_names.items(): val = wk[k] - lit = f"{val:#04x}" if val < 256 and const not in ("TEMPO_BPM_SCALE", "METER_COUNT") else str(val) + lit = f"{val:#04x}" if val < 256 and const not in ("TEMPO_BPM_SCALE", "METER_COUNT", "BANK_SLOTS") else str(val) w(f"{const} = {lit}") w("") w(f'METER_PAGE = {mb["page"]:#04x}') @@ -386,6 +438,10 @@ def emit_python(d: dict) -> str: sorted(d["effect-types"]["effect_types"], key=lambda e: e["value"])) w(f"EFFECT_TYPES = {{\n {rows},\n}}") w("") + rows = ",\n ".join(f"({e['min']}, {e['max']}, {q(e['name'])})" + for e in d["effect-types"]["effect_categories"]) + w(f"# (min, max, name) — inclusive Type value blocks\nEFFECT_CATEGORIES = [\n {rows},\n]") + w("") for c in d["controls"]["cc"]: w(f"{cc_const_name(c['name'])} = {c['cc']}") w("") @@ -433,6 +489,7 @@ def c(name, ty, val): c("port", "UInt16", p["transport"]["port"]) c("connectTimeoutSecs", "UInt64", p["transport"]["connect_timeout_secs"]) c("socketTimeoutSecs", "UInt64", p["transport"]["socket_timeout_secs"]) + c("connectionCooldownMs", "UInt64", p["transport"]["connection_cooldown_ms"]) c("discoveryHeader", "String", q(p["discovery"]["header"])) c("pollIntervalMs", "UInt64", p["discovery"]["poll_interval_ms"]) c("pollMacPrefix", "String", q(p["discovery"]["poll_mac_prefix"])) @@ -447,6 +504,17 @@ def c(name, ty, val): c("protocolRequestResponse", "String", q(p["protocols"]["request_response"])) c("protocolCborControl", "String", q(p["protocols"]["cbor_control"])) c("protocolReserved", "String", q(p["protocols"]["reserved"])) + cb = p["cbor"] + c("cborItemTag", "UInt64", cb["item_tag"]) + c("cborSelectorSingle", "Int64", cb["selector_single"]) + c("cborSelectorMulti", "Int64", cb["selector_multi"]) + c("cborSelectorString", "Int64", cb["selector_string"]) + c("cborFillerByte", "UInt8", f'{cb["filler_byte"]:#04x}') + c("stateDumpTriggerAddress", "UInt32", cb["state_dump_trigger_address"]) + c("stateDumpTriggerValue", "Int64", cb["state_dump_trigger_value"]) + sens = ", ".join(str(a) for a in cb["sensitive_addresses"]) + c("sensitiveAddresses", "[UInt32]", f"[{sens}]") + c("redactedPlaceholder", "String", q(cb["redacted_placeholder"])) c("midi3TagContinuation", "UInt8", f'{p["midi3"]["tag_continuation"]:#04x}') c("midi3TagFinal1", "UInt8", f'{p["midi3"]["tag_final_1"]:#04x}') c("midi3TagFinal2", "UInt8", f'{p["midi3"]["tag_final_2"]:#04x}') @@ -489,11 +557,18 @@ def c(name, ty, val): "amp_page": ("ampPage", "UInt8"), "amp_on_number": ("ampOnNumber", "UInt8"), "gain_number": ("gainNumber", "UInt8"), "system_page": ("systemPage", "UInt8"), "main_volume_number": ("mainVolumeNumber", "UInt8"), - "monitor_volume_number": ("monitorVolumeNumber", "UInt8"), "page_morph": ("pageMorph", "UInt8"), + "headphone_volume_number": ("headphoneVolumeNumber", "UInt8"), + "monitor_volume_number": ("monitorVolumeNumber", "UInt8"), + "page_bank_preview": ("pageBankPreview", "UInt8"), "bank_slots": ("bankSlots", "Int"), + "bank_rig_name_base": ("bankRigNameBase", "UInt8"), + "bank_amp_name_base": ("bankAmpNameBase", "UInt8"), + "bank_cabinet_name_base": ("bankCabinetNameBase", "UInt8"), "page_morph": ("pageMorph", "UInt8"), "morph_number": ("morphNumber", "UInt8"), "page_tuner_note": ("pageTunerNote", "UInt8"), "tuner_note_number": ("tunerNoteNumber", "UInt8"), "tuner_in_tune_center": ("tunerInTuneCenter", "UInt16"), "tuner_in_tune_window": ("tunerInTuneWindow", "UInt16"), "meter_count": ("meterCount", "Int"), + "current_bank_address": ("currentBankAddress", "UInt32"), + "current_rig_slot_address": ("currentRigSlotAddress", "UInt32"), } for k, (const, ty) in wk_sw.items(): val = wk[k] @@ -537,6 +612,10 @@ def c(name, ty, val): rows = ",\n ".join(f"{e['value']}: {q(e['name'])}" for e in sorted(d["effect-types"]["effect_types"], key=lambda e: e["value"])) w(f" public static let effectTypes: [UInt16: String] = [\n {rows},\n ]") + rows = ",\n ".join( + f"EffectCategory(min: {e['min']}, max: {e['max']}, name: {q(e['name'])})" + for e in d["effect-types"]["effect_categories"]) + w(f" public static let effectCategories: [EffectCategory] = [\n {rows},\n ]") rows = ", ".join(f"{q(e['slot'])}: {e['cc']}" for e in d["controls"]["slot_enable_cc"]) w(f" public static let slotEnableCc: [String: UInt8] = [{rows}]") rows = ",\n ".join( @@ -560,6 +639,12 @@ def c(name, ty, val): w(" public let render: String") w("}") w("") + w("public struct EffectCategory {") + w(" public let min: UInt16") + w(" public let max: UInt16") + w(" public let name: String") + w("}") + w("") return "\n".join(out) + "\n" diff --git a/docs/05-sysex-nrpn.md b/docs/05-sysex-nrpn.md index 0325ae8..c37570b 100644 --- a/docs/05-sysex-nrpn.md +++ b/docs/05-sysex-nrpn.md @@ -189,16 +189,19 @@ Framed for the wire, this becomes `14f000201433007f1401003d1403000115f70000` — see [MIDI3 framing](04-midi3-framing.md). -The device echoes the change back on the stream as a `$01` from -`product 00, device 00`, which is how a state model stays consistent: +The device applies that write, but — established by observed experimentation — +it does **not** echo it back on a plain streaming session. A client whose state +store must reflect the applied value follows the write with a `$41` single +parameter request for the same address; the reply is an ordinary `$01` from +`product 00, device 00` and flows through normal ingest: ``` f0 00 20 33 00 00 01 00 3d 03 00 01 f7 ``` -That echo also fires for changes made at the **front panel**, so a client never -has to poll. Read-back after a write lands roughly a second later; do not treat -the absence of an immediate echo as failure. +The reply lands roughly a second later, so order state changes before their +read-backs and give a confirmation poll a couple of seconds before deciding a +write failed. `$01` may carry an **optional trailing 14-bit pair**, the morph "B value" for the same address. A parser must therefore accept 2 *or* 4 value bytes and use @@ -364,8 +367,8 @@ Semantics: `$7E`, instance `$00`, page `$7F`. No sense for 1.5 s means the link is down. - Re-send the beacon at **half the lease** to renew it. Init beacons are retried every 5 s until sensing starts. -- The realtime status block and the `$01` change echoes are pushed **without** - any beacon, so a monitoring client can skip the beacon entirely. It is needed +- The realtime status block and the `$01` pushes for device-side changes arrive + **without** any beacon, so a monitoring client can skip it entirely. It is needed only for the set-`$02` subscription and the sense heartbeat. Constants: `[beacon]` in [`../spec/protocol.toml`](../spec/protocol.toml). diff --git a/docs/08-control-model.md b/docs/08-control-model.md index c84f699..3c54d44 100644 --- a/docs/08-control-model.md +++ b/docs/08-control-model.md @@ -3,7 +3,7 @@ The same outcome can often be reached more than one way — gain is a Control Change *and* an NRPN parameter; an effect can be switched by either. The difference is not cosmetic: one is 7-bit and unobservable, the other is 14-bit -and echoed back. This document sets out the three layers, and which one is +and readable back. This document sets out the three layers, and which one is canonical for each capability. ## Three layers @@ -31,9 +31,11 @@ Constants: [`../spec/controls.toml`](../spec/controls.toml); vectors: `$01` Single Parameter Change and its request forms, addressed by `(page, number)` — see [SysEx / NRPN dialect](05-sysex-nrpn.md). Every parameter the device has is reachable here, not just the ones with a CC. Values are 14-bit -(0–16383), settable with `$01`, readable with `$41`/`$42`/`$43`, and — decisively -— **the device echoes every change on the same stream**, including changes made -at the front panel. That echo is what makes a consistent state model possible. +(0–16383), settable with `$01`, and — decisively — **readable back** with +`$41`/`$42`/`$43`, whose replies arrive on the same stream. That read-back is +what makes a consistent state model possible. A `$01` write is applied silently: +established by observed experimentation, the device does not echo it, so a +client that wants its store to confirm a write issues the matching `$41`. ### 3. The DeviceModel — the curated surface @@ -41,7 +43,8 @@ A single object that ingests the stream, tracks state, and exposes a small labelled API. Its methods split cleanly in two: - **Parameters** — NRPN-backed, 14-bit, tracked in state. Setting one updates the - model when the echo arrives; reading one is answered from state, not the wire. + model when the read-back reply arrives; reading one is answered from state, not + the wire. - **Actions** — CC-backed, momentary or expression. Nothing is stored, because there is nothing to store. @@ -162,7 +165,9 @@ slot numbers are **clamped** to 1–5, and effect-button numbers to 1–4. |---|---|---|---| | Set gain | NRPN `$0A`/4 | CC 72 | 14-bit precision, and it reads back | | Rig volume | NRPN `$04`/1 | — | value with read-back | -| Main / monitor volume | NRPN `$7F`/0, `$7F`/2 | monitor also CC 73 | precision + read-back | +| Main / monitor / headphone volume | NRPN `$7F`/0, `$7F`/2, `$7F`/1 | monitor also CC 73 | precision + read-back | +| Master volume (read) | NRPN `$7F`/1 = `$7F`/2 | — | the physical knob; read-only (see below) | +| Bank rig/amp/cab names (read) | ext-string page `$96`/0–14 | — | the current bank's five-slot preview | | Effect on/off | NRPN ``/3 | CC 17–29 | tracked in the slot state | | Effect mix | NRPN ``/4 | CC 68 / 70 (delay, reverb only) | precision + read-back | | Effect **type** | NRPN ``/0, **read-only** | — | set by loading a rig, not over MIDI | @@ -170,8 +175,8 @@ slot numbers are **clamped** to 1–5, and effect-button numbers to 1–4. | Morph position (read) | NRPN `$00`/`$0B` | — | observed state | | Looper transport | NRPN `$7D`/88–94 | — | latched values | | Freeze per module | NRPN `$7D`/107–111, 113–115 | CC 35 (global) | per-slot state | -| **Rig select 1–5** | **CC 50–54** | — | no NRPN equivalent — an action | -| **Rig up / down** | **CC 48 / 49** | — | navigation | +| **Rig select 1–5** | **CC 50–54** | — | no NRPN equivalent — a momentary action | +| **Rig up / down** | **CC 48 / 49** | — | navigation; momentary | | **Bank preselect** | **CC 47** | — | navigation; loads on the next rig select | | **Tap tempo** | **CC 30** | — | momentary event | | **Tuner mode** | **CC 31** | state readable at NRPN `$7F`/126 | set momentarily, read as state | @@ -180,12 +185,58 @@ slot numbers are **clamped** to 1–5, and effect-button numbers to 1–4. | **Freeze / infinity / rotary** | **CC 35 / 34 / 33** | — | momentary | | **Wah / pitch / volume / morph pedal** | **CC 1 / 4 / 7 / 11** | — | live expression | +**The navigation controls are momentary, and the release is not optional.** +CC 48, CC 49 and CC 50–54 are button presses: value 1 presses, value 0 releases. +A press on its own *does* take effect — the device loads the target rig and +pushes the new bank's name preview — but if the release never arrives it +abandons the change and reloads the previous rig about two seconds later, which +looks exactly like the device spontaneously undoing the navigation. Sending +value 0 alone is inert, being the release of a press that never happened. The +`Control` type therefore renders `up`, `down` and `load_slot` as a press +immediately followed by its release, one 6-byte message; a caller that wants to +model a genuinely held button has to build the two Control Changes itself. + +**The device says where it is, in channel-voice messages.** Alongside the SysEx +it emits a Bank Select LSB (CC 32) followed by a Program Change on every rig +change — from the front panel as readily as from a controller. The two carry a +flat, 0-based rig index, `128 × CC32 + program`, which divides by the five slots +per bank into bank and slot: index 123 is bank 25, slot 4. This is the only +position report the streaming session gives; the CBOR state dump is a one-shot +read at connect. A Program Change with no Bank Select ahead of it is half an +index, not a position, and is ignored. + +That index is also the only address that names a rig **outside** the current +bank, so it is what navigation is computed in: ±1 is the next or previous rig, +±5 the next or previous bank, and any rig is reachable by sending the absolute +bank preselect (CC 47) followed by the slot load. Bank boundaries stop being +special. Note that CC 48 / 49 are *not* a general bank control — on at least one +device they alternate between two banks rather than stepping — so a client that +needs to reach an arbitrary bank should use CC 47 and not step. + +How many rigs a device holds varies, and nothing in the protocol announces it. +Rather than assume a ceiling, aim: the device stays put if the target does not +exist, and its next position report says where it actually is. + Two rows deserve a second look. **Effect type is read-only**: there is no way to change what an effect *is* over MIDI — that happens by loading a rig — so a client reads ``/0 and looks the value up in the effect-type table. And **tuner mode is split**: CC 31 sets it, NRPN `$7F`/126 reports it, so a UI that wants a correct toggle state must read one address while writing to another. +**Master volume is a potentiometer, not a stored value.** The device's physical +master-volume knob drives Headphone (`$7F`/1) and Monitor (`$7F`/2) together, 1:1, +under the default output routing (Main, `$7F`/0, is independent). It has an +absolute position — unlike every other knob, which is an endless encoder — so the +pot is ground truth: there is no soft-takeover, and a value written to `$7F`/1 or +`$7F`/2 is authoritative only until the knob next moves. The model therefore +exposes master volume as a **read-only readout** (`Output.master_volume`, which +reports Headphone and falls back to Monitor) and ships no `set_master_volume`. + +**Bank names are a five-slot preview.** Page `$96` (150) carries the loaded +bank's five rig names (numbers 0–4), their amps (5–9) and cabinets (10–14). The +device pushes the whole block on a bank change, and it is readable on demand as +extended strings (function `$47` → `$07`). The model folds it into `state.bank`. + ## The DeviceModel surface **Parameters** — NRPN-backed, state-tracked: @@ -207,11 +258,22 @@ delay_infinity(on) toggle_all_modules wah_pedal pitch_pedal volume_pedal panorama morph_pedal ``` -A caller that wants "turn reverb off" calls `set_effect_enabled("REV", false)` -and sees the change reflected in the model's state once the echo lands. A caller -building a foot controller uses the actions. Anyone needing a control the model -does not name reaches the complete raw vocabulary through the control module and -its send-control entry point, or an arbitrary address through `set_param`. +**Requests** — read-only, they change nothing on the device: + +``` +refresh_rig refresh_bank request_param(page, number) +request_string(page, number) request_render(page, number, value) +``` + +`refresh_bank` issues the fifteen `$47` extended-string requests for the current +bank's rig/amp/cabinet names; `connect` runs it once alongside `refresh_rig`. + +A caller that wants "turn reverb off" calls `set_effect_enabled("REV", false)`, +then `request_param(page, 3)` and sees the change reflected in the model's state +once the reply lands. A caller building a foot controller uses the actions. +Anyone needing a control the model does not name reaches the complete raw +vocabulary through the control module and its send-control entry point, or an +arbitrary address through `set_param`. ## Consequences to design around @@ -221,9 +283,22 @@ Program Change on the stream. A client that wants to display the current rig mus **request the rig name** (`$43`, page 0, number 1) after switching. See [SysEx / NRPN dialect](05-sysex-nrpn.md). -**Read-back is not immediate.** A `$01` write is echoed roughly a second later. -Order state changes before read-backs, and give a confirmation poll at least a -couple of seconds before deciding a write failed. +**The stream never states the current *position*.** Rig-name requests tell you +*what* is loaded, not *where* it sits. Matching the loaded name against the +[bank preview](09-parameter-registry.md) recovers the slot, but not the bank +number, and it is ambiguous when two slots share a name. The device's actual +0-based bank and rig indices are only reachable over the +[CBOR channel](06-cbor-channel.md) — request its state dump and read addresses +100701 / 100702. `StateSnapshot::fetch` does exactly that in one call, over its +own short-lived session; run it at boot, before opening the streaming model, to +show the right patch immediately. + +**A write is not echoed.** Established by observed experimentation, the device +applies a `$01` write without reporting it back on a plain streaming session, so +a store that must confirm the applied value follows the write with a `$41` +request. The reply lands roughly a second later: order state changes before +read-backs, and give a confirmation poll at least a couple of seconds before +deciding a write failed. **A rig change is a windfall.** The device unprompted dumps the entire new rig — name, author, comment, amp, cabinet, microphone, speaker, every effect slot's @@ -239,5 +314,6 @@ The Control Change map and the NRPN parameter grammar follow the [Kemper MIDI Parameter Documentation](https://www.kemper-amps.com/downloads/5/User-Manuals), cross-checked against [PySwitch](https://github.com/Tunetown/PySwitch), which is credited for the tuner-mode state address and the rig/bank selection scheme. The -absence of Program Change feedback on the network link was established by -observed experimentation. See [../CREDITS.md](../CREDITS.md). +absence of Program Change feedback on the network link, and the fact that a +`$01` write is applied without being echoed, were established by observed +experimentation. See [../CREDITS.md](../CREDITS.md). diff --git a/docs/09-parameter-registry.md b/docs/09-parameter-registry.md index febc949..3d8ee63 100644 --- a/docs/09-parameter-registry.md +++ b/docs/09-parameter-registry.md @@ -13,6 +13,7 @@ device, no network, no state. | `page` | section name | | string-tag number (page 0) | tag name | | effect-type value | effect name | +| effect-type value | category name | | `(page, number)` | a display string, with a structural fallback | All of it is offline and total — every lookup either returns a name or `null`, @@ -41,13 +42,16 @@ Two of those deserve comment. `param_name(50, 0)` and `param_name(61, 4)` are pages `$32` and `$3D` — modules A and REV — resolving through the **shared effect map** described below. And `param_name(118, 5)` comes from a **range entry**: the spec declares User Scale steps as `page $76, numbers 0–11` and -`12–23` rather than 24 separate rows, and the generator expands them. +`12–23` rather than 24 separate rows, and the generator expands them. The +**Bank Preview** page (`$96` / 150) is declared the same way — numbers 0–4 are +`"Bank Rig Name"`, 5–9 `"Bank Amp Name"`, 10–14 `"Bank Cabinet Name"`. Section names come from the page table: ``` page_name(124) → "Realtime/Meters" page_name(10) → "Amplifier" +page_name(150) → "Bank Preview" page_name(153) → null ``` @@ -154,6 +158,37 @@ effect_type_name(5) → null The table is sparse — values 5, 14–16, 22–31 and many others are unassigned — so `null` is expected and means "no effect type with that value", not a bug. +### Type categories + +The Type values are not scattered: Appendix B allocates them in 16-value blocks +(a few split into half-blocks), one block per group of the device's type knob. +The registry carries those blocks as a second lookup, so a UI can label a slot +with its family without knowing the individual type: + +``` +effect_category_name(0) → null +effect_category_name(16) → "Wah" +effect_category_name(17) → "Shaper" +effect_category_name(76) → "Modulation" +effect_category_name(179) → "Reverb" +effect_category_name(300) → null +``` + +| Values | Category | Values | Category | +|---|---|---|---| +| 1–16 | Wah | 96–111 | Equalizer | +| 17–31 | Shaper | 112–120 | Booster | +| 32–48 | Distortion | 121–127 | Effect Loop | +| 49–63 | Dynamics | 128–143 | Pitch | +| 64–79 | Modulation | 144–175 | Delay | +| 80–95 | Phaser & Flanger | 176–207 | Reverb | + +Unlike the name table this one is *dense within a block*, so a type value the +spec has no name for still gets a category — 76 is unnamed but plainly a +modulation. Value 0 ("empty") and anything past the last block return `null`. +The blocks are `effect_categories` in +[`../spec/effect-types.toml`](../spec/effect-types.toml). + Put together, this is the whole effect-block view the [`meters`](07-realtime-status.md#the-meters-example) example renders: @@ -189,7 +224,7 @@ from [`../spec/`](../spec): | File | Supplies | |---|---| | `parameters.toml` | pages, the shared effect map, non-effect parameters, range entries, string tags, page-0 numerics, well-known addresses | -| `effect-types.toml` | the effect Type value → name table | +| `effect-types.toml` | the effect Type value → name table, and the category blocks | | `controls.toml` | the CC vocabulary and the per-slot enable CCs | | `meters.toml` | the realtime status block's field identities | | `protocol.toml` | transport, handshake, framing and SysEx constants | @@ -212,8 +247,9 @@ you to do, is in The page/number map, the shared effect-module parameter list, the string tags, and the effect-type table are transcribed from the -[Kemper MIDI Parameter Documentation](https://www.kemper-amps.com/downloads/5/User-Manuals). -The Fixed-FX page, several tuner and tempo addresses, and the numeric morph-state +[Kemper MIDI Parameter Documentation](https://www.kemper-amps.com/downloads/5/User-Manuals); +the effect-type category blocks are inferred from the value-range structure of +its Appendix B and the device's type-knob grouping. The Fixed-FX page, several tuner and tempo addresses, and the numeric morph-state parameter are credited to [PySwitch](https://github.com/Tunetown/PySwitch). The page `$7C` meter-field identities were established by observed experimentation. See [../CREDITS.md](../CREDITS.md). diff --git a/python/src/libkp/_generated.py b/python/src/libkp/_generated.py index 4b42181..7f3ef6a 100644 --- a/python/src/libkp/_generated.py +++ b/python/src/libkp/_generated.py @@ -1,10 +1,11 @@ """GENERATED FILE — DO NOT EDIT. Edit spec/*.toml and run codegen/generate.py.""" -SPEC_VERSION = "0.1.0" +SPEC_VERSION = "0.5.0" PORT = 5727 CONNECT_TIMEOUT_SECS = 5 SOCKET_TIMEOUT_SECS = 15 +CONNECTION_COOLDOWN_MS = 1000 DISCOVERY_HEADER = "DSCV" POLL_INTERVAL_MS = 500 @@ -23,6 +24,16 @@ PROTOCOL_CBOR_CONTROL = "{774CDB9E-74ED-4740-AF09-AC96B3A69A11}" PROTOCOL_RESERVED = "{77DB6B28-785E-4641-B840-42F0F06A11FC}" +CBOR_ITEM_TAG = 1 +CBOR_SELECTOR_SINGLE = 1 +CBOR_SELECTOR_MULTI = 2 +CBOR_SELECTOR_STRING = 4 +CBOR_FILLER_BYTE = 0xc0 +STATE_DUMP_TRIGGER_ADDRESS = 102528 +STATE_DUMP_TRIGGER_VALUE = 1 +SENSITIVE_ADDRESSES = (200008, 200009) +REDACTED_PLACEHOLDER = "[redacted]" + MIDI3_TAG_CONTINUATION = 0x14 MIDI3_TAG_FINAL_1 = 0x15 MIDI3_TAG_FINAL_2 = 0x16 @@ -76,7 +87,13 @@ GAIN_NUMBER = 0x04 SYSTEM_PAGE = 0x7f MAIN_VOLUME_NUMBER = 0x00 +HEADPHONE_VOLUME_NUMBER = 0x01 MONITOR_VOLUME_NUMBER = 0x02 +PAGE_BANK_PREVIEW = 0x96 +BANK_SLOTS = 5 +BANK_RIG_NAME_BASE = 0x00 +BANK_AMP_NAME_BASE = 0x05 +BANK_CABINET_NAME_BASE = 0x0a PAGE_MORPH = 0x00 MORPH_NUMBER = 0x0b PAGE_TUNER_NOTE = 0x7d @@ -84,6 +101,8 @@ TUNER_IN_TUNE_CENTER = 8192 TUNER_IN_TUNE_WINDOW = 350 METER_COUNT = 11 +CURRENT_BANK_ADDRESS = 100701 +CURRENT_RIG_SLOT_ADDRESS = 100702 METER_PAGE = 0x7c METER_FIRST_NUMBER = 0x4e @@ -93,7 +112,7 @@ FUNCTION_NAMES = {0x01: "single-param", 0x02: "multi-param", 0x03: "string-param", 0x04: "blob", 0x06: "ext-param", 0x07: "ext-string-param", 0x08: "morphed-multi-param", 0x3c: "rendered-string-reply", 0x41: "request-single", 0x42: "request-multi", 0x43: "request-string", 0x47: "request-ext-string", 0x7c: "request-rendered-string", 0x7e: "beacon"} -PAGE_NAMES = {0x00: "String Tags", 0x04: "Rig Settings", 0x05: "Fixed FX", 0x09: "Input Section", 0x0a: "Amplifier", 0x0b: "Amplifier EQ", 0x0c: "Cabinet", 0x32: "Effect A", 0x33: "Effect B", 0x34: "Effect C", 0x35: "Effect D", 0x38: "Effect X", 0x3a: "Effect MOD", 0x3c: "Effect DLY", 0x3d: "Effect REV", 0x76: "User Scales", 0x7c: "Realtime/Meters", 0x7d: "Looper/Freeze", 0x7f: "System/Global"} +PAGE_NAMES = {0x00: "String Tags", 0x04: "Rig Settings", 0x05: "Fixed FX", 0x09: "Input Section", 0x0a: "Amplifier", 0x0b: "Amplifier EQ", 0x0c: "Cabinet", 0x32: "Effect A", 0x33: "Effect B", 0x34: "Effect C", 0x35: "Effect D", 0x38: "Effect X", 0x3a: "Effect MOD", 0x3c: "Effect DLY", 0x3d: "Effect REV", 0x76: "User Scales", 0x7c: "Realtime/Meters", 0x7d: "Looper/Freeze", 0x7f: "System/Global", 0x96: "Bank Preview"} EFFECT_SLOTS = [("A", 0x32), ("B", 0x33), ("C", 0x34), ("D", 0x35), ("X", 0x38), ("MOD", 0x3a), ("DLY", 0x3c), ("REV", 0x3d)] @@ -138,6 +157,7 @@ (0x0a, 11): "Tube Shape", (0x0a, 12): "Tube Bias", (0x0a, 15): "Direct Mix", + (0x0a, 20): "Gain (smoothed follower)", (0x0a, 21): "Bright Cap Intensity", (0x0b, 4): "Bass", (0x0b, 5): "Middle", @@ -238,6 +258,21 @@ (0x7f, 53): "Looper Location", (0x7f, 59): "Aux >Mono", (0x7f, 126): "Tuner Mode State", + (0x96, 0): "Bank Rig Name", + (0x96, 1): "Bank Rig Name", + (0x96, 2): "Bank Rig Name", + (0x96, 3): "Bank Rig Name", + (0x96, 4): "Bank Rig Name", + (0x96, 5): "Bank Amp Name", + (0x96, 6): "Bank Amp Name", + (0x96, 7): "Bank Amp Name", + (0x96, 8): "Bank Amp Name", + (0x96, 9): "Bank Amp Name", + (0x96, 10): "Bank Cabinet Name", + (0x96, 11): "Bank Cabinet Name", + (0x96, 12): "Bank Cabinet Name", + (0x96, 13): "Bank Cabinet Name", + (0x96, 14): "Bank Cabinet Name", } STRING_TAGS = {1: "Rig Name", 2: "Rig Author", 3: "Rig Creation Date", 4: "Rig Comment", 10: "Amp Name", 11: "Amp Author", 14: "Amp Location", 15: "Amp Manufacturer", 16: "Amp Comment", 18: "Amp Model", 19: "Amp Channel", 20: "Pickup Type", 21: "Year of Production", 32: "Cabinet Name", 33: "Cabinet Author", 36: "Cabinet Location", 37: "Cabinet Manufacturer", 38: "Microphone Model", 39: "Cabinet Comment", 40: "Microphone Position", 41: "Speaker Configuration", 42: "Cabinet Model", 44: "Speaker Manufacturer", 45: "Speaker Model"} @@ -336,6 +371,22 @@ 193: "Spring Reverb", } +# (min, max, name) — inclusive Type value blocks +EFFECT_CATEGORIES = [ + (1, 16, "Wah"), + (17, 31, "Shaper"), + (32, 48, "Distortion"), + (49, 63, "Dynamics"), + (64, 79, "Modulation"), + (80, 95, "Phaser & Flanger"), + (96, 111, "Equalizer"), + (112, 120, "Booster"), + (121, 127, "Effect Loop"), + (128, 143, "Pitch"), + (144, 175, "Delay"), + (176, 207, "Reverb"), +] + CC_WAH_PEDAL = 1 CC_PITCH_PEDAL = 4 CC_VOLUME_PEDAL = 7 diff --git a/python/src/libkp/control.py b/python/src/libkp/control.py index 3984aba..a216d10 100644 --- a/python/src/libkp/control.py +++ b/python/src/libkp/control.py @@ -293,6 +293,21 @@ def message(self, channel: int = 0) -> bytes: return control_change(channel, self.CONTROLLER, 1) +class _MomentaryCC(_CCTrigger): + """A Control Change the device treats as a button press. + + Value 1 is the press; the device needs the matching value-0 release to + complete the gesture. Left held, it abandons the change and reloads the + previous rig a couple of seconds later, so both halves go out together as + one 6-byte message. + """ + + def message(self, channel: int = 0) -> bytes: + return control_change(channel, self.CONTROLLER, 1) + control_change( + channel, self.CONTROLLER, 0 + ) + + # Continuous controllers ---------------------------------------------------- @@ -436,15 +451,15 @@ class TapTempo(_CCTrigger): @dataclass(frozen=True) -class Up(_CCTrigger): - """Performance/Rig up (CC48).""" +class Up(_MomentaryCC): + """Performance/Rig up (CC48), as a press/release pair.""" CONTROLLER: ClassVar[int] = CC_UP @dataclass(frozen=True) -class Down(_CCTrigger): - """Performance/Rig down (CC49).""" +class Down(_MomentaryCC): + """Performance/Rig down (CC49), as a press/release pair.""" CONTROLLER: ClassVar[int] = CC_DOWN @@ -469,13 +484,17 @@ def message(self, channel: int = 0) -> bytes: @dataclass(frozen=True) class LoadSlot(Control): - """Load a performance slot 1-5 (CC50-54, value 1). ``n`` is clamped to 1..5.""" + """Load a performance slot 1-5 (CC50-54), as a press/release pair. + + ``n`` is clamped to 1..5. The load is momentary: see :class:`_MomentaryCC`. + """ n: int def message(self, channel: int = 0) -> bytes: n = min(max(self.n, 1), 5) - return control_change(channel, CC_LOAD_SLOT_1 + (n - 1), 1) + controller = CC_LOAD_SLOT_1 + (n - 1) + return control_change(channel, controller, 1) + control_change(channel, controller, 0) @dataclass(frozen=True) diff --git a/python/src/libkp/model.py b/python/src/libkp/model.py index 26509ae..3f31862 100644 --- a/python/src/libkp/model.py +++ b/python/src/libkp/model.py @@ -26,10 +26,11 @@ Parameters vs actions --------------------- -- **Parameters** (``set_*``) — settable values the device also reports back. - They go out as 14-bit NRPN ``$01`` Single Parameter Changes, so the device - echoes the change on the same stream the model ingests and :meth:`state` stays - consistent. +- **Parameters** (``set_*``) — settable values the device stores. They go out as + 14-bit NRPN ``$01`` Single Parameter Changes; the device applies the write + silently and does *not* echo it back on the stream, so follow a set with + :meth:`DeviceModel.request_param` when :meth:`state` should confirm the new + value — the ``$41`` reply flows through normal ingest. - **Actions** (verbs) — momentary presses and live expression that carry no stored value. They go out as 7-bit Control Change messages from the :mod:`libkp.control` vocabulary and are *not* reflected in state. @@ -52,6 +53,7 @@ ApplyOutcome, Connected, Connection, + CurrentPosition, DeviceEvent, DeviceState, Disconnected, @@ -180,6 +182,7 @@ async def connect( if sync: await model.refresh_rig() + await model.refresh_bank() return model async def close(self) -> None: @@ -320,6 +323,40 @@ async def refresh_rig(self) -> None: nrpn.request_single(PRODUCT, DEVICE, page, params.EFFECT_PARAM_STATE) ) + async def refresh_bank(self) -> None: + """Request the current bank's five-slot name preview (rig / amp / cabinet + names) as extended strings (``$47``). + + The ``$07`` replies fold into :attr:`~libkp.state.DeviceState.bank`. + Read-only: it changes nothing on the device. The device also pushes this + block unasked on a bank change, so a controller need only call this once + at connect. + """ + for field_ in params.BankPreviewField: + for slot in range(1, params.BANK_SLOTS + 1): + address = params.bank_preview_address(field_, slot) + await self._enqueue(nrpn.request_extended_string(PRODUCT, DEVICE, address)) + + def set_current_position(self, bank: int | None, slot: int | None) -> None: + """Fold a :class:`~libkp.cbor.StateSnapshot`'s current bank and rig slot + into the state tree, emitting a + :class:`~libkp.state.CurrentPosition` event and broadcasting a fresh + snapshot. + + The MIDI3 stream never reports these indices; a controller learns them by + running :func:`libkp.cbor.fetch_state_snapshot` over a separate CBOR + session — which the device wants done *before* this streaming session + opens, not concurrently — and applying the result here. Only the non-``None`` + arguments overwrite; a ``None`` leaves the current value untouched. + Synchronous: no I/O. + """ + if bank is not None: + self._state.current_bank = bank + if slot is not None: + self._state.current_rig_slot = slot + self._emit(CurrentPosition(bank=bank, slot=slot)) + self._snapshots.send(self._state.snapshot()) + async def request_param(self, page: int, number: int) -> None: """Request one numeric parameter (``$41``); the reply arrives as ``$01``.""" await self._enqueue(nrpn.request_single(PRODUCT, DEVICE, page, number)) @@ -365,6 +402,24 @@ async def bank(self, n: int) -> None: """Preselect bank ``n`` (1-based; CC47). Takes effect with the next rig.""" await self.send_control(control_mod.BankPreselect(max(n - 1, 0))) + async def select_rig_index(self, index: int) -> None: + """Load a rig by its flat, 0-based index. + + This is the device's own numbering, and the only address that reaches a + rig outside the current bank. Sent as the documented pair: the absolute + bank preselect (CC47) followed by the slot load (CC50-54) that commits + it. The index divides by ``BANK_SLOTS``, so index 123 is bank 25, slot 4. + + Nothing here assumes how many banks a device has. Aim past the end and + the device simply stays where it is -- and says so in the Bank Select / + Program Change report that follows, so + :attr:`~libkp.state.DeviceState.current_rig_index` always reflects where + it actually landed, not where this aimed. + """ + bank, slot = divmod(index, gen.BANK_SLOTS) + await self.bank(bank + 1) + await self.select_rig(slot + 1) + async def tap_tempo(self) -> None: """Tap the tempo (CC30).""" await self.send_control(control_mod.TapTempo()) diff --git a/python/src/libkp/nrpn.py b/python/src/libkp/nrpn.py index 160e497..6bedb23 100644 --- a/python/src/libkp/nrpn.py +++ b/python/src/libkp/nrpn.py @@ -34,6 +34,7 @@ "FUNCTION_REQUEST_SINGLE", "FUNCTION_REQUEST_MULTI", "FUNCTION_REQUEST_STRING", + "FUNCTION_REQUEST_EXT_STRING", "FUNCTION_REQUEST_RENDERED_STRING", "FUNCTION_BEACON", "NrpnHeader", @@ -45,6 +46,7 @@ "request_string", "request_single", "request_multi", + "request_extended_string", "request_rendered_string", "set_single", "control_change", @@ -83,6 +85,7 @@ FUNCTION_REQUEST_SINGLE: int = gen.FN_REQUEST_SINGLE FUNCTION_REQUEST_MULTI: int = gen.FN_REQUEST_MULTI FUNCTION_REQUEST_STRING: int = gen.FN_REQUEST_STRING +FUNCTION_REQUEST_EXT_STRING: int = gen.FN_REQUEST_EXT_STRING FUNCTION_REQUEST_RENDERED_STRING: int = gen.FN_REQUEST_RENDERED_STRING FUNCTION_BEACON: int = gen.FN_BEACON @@ -201,6 +204,24 @@ def request_multi(product: int, device: int, page: int, number: int) -> bytes: return sysex(product, device, FUNCTION_REQUEST_MULTI, page, number) +def request_extended_string(product: int, device: int, address: int) -> bytes: + """Request an extended string parameter (function ``$47``) at a flat address + (``page * 128 + number``); the device replies with ``$07`` (or ``$03`` below + the 14-bit range). Read-only. + + Layout mirrors ``$07`` minus the payload: + ``F0 00 20 33 47 <5-byte address> F7``. This is how the + current bank's rig/amp/cabinet names are read on demand — the addresses come + from :func:`libkp.params.bank_preview_address`. + """ + out = bytearray([0xF0]) + out.extend(MANUFACTURER_ID) + out.extend([product, device, FUNCTION_REQUEST_EXT_STRING, 0x00]) + out.extend(ext_encode(address, 5)) + out.append(0xF7) + return bytes(out) + + def set_single(product: int, device: int, page: int, number: int, value: int) -> bytes: """Set a single numeric parameter (function ``$01``, Single Parameter Change). diff --git a/python/src/libkp/params.py b/python/src/libkp/params.py index 8a2e69f..87efefd 100644 --- a/python/src/libkp/params.py +++ b/python/src/libkp/params.py @@ -10,6 +10,8 @@ from __future__ import annotations +from enum import Enum + from . import _generated as gen __all__ = [ @@ -20,6 +22,11 @@ "EFFECT_PARAM_STATE", "EFFECT_PARAM_MIX", "EFFECT_PARAM_VOLUME", + "PAGE_BANK_PREVIEW", + "BANK_SLOTS", + "BankPreviewField", + "bank_preview_address", + "bank_preview_slot_field", "function_name", "page_name", "param_name", @@ -28,6 +35,7 @@ "effect_slot_name", "effect_slot_index", "effect_type_name", + "effect_category_name", "string_tag_name", "page0_numeric_name", "describe", @@ -108,6 +116,18 @@ def effect_type_name(value: int) -> str | None: return gen.EFFECT_TYPES.get(value) +def effect_category_name(value: int) -> str | None: + """The category of an effect Type value — the group of the device's type knob + it belongs to, derived from the block structure of Appendix B's value ranges. + + ``None`` for 0 (``"empty"``) and for values in no block. + """ + for low, high, name in gen.EFFECT_CATEGORIES: + if low <= value <= high: + return name + return None + + def string_tag_name(number: int) -> str | None: """String-tag names on page 0 — functions ``$03``/``$43`` only.""" return gen.STRING_TAGS.get(number) @@ -119,6 +139,49 @@ def page0_numeric_name(number: int) -> str | None: return gen.PAGE0_NUMERIC.get(number) +#: Page holding the loaded bank's five-slot name preview (rig/amp/cabinet). +PAGE_BANK_PREVIEW: int = gen.PAGE_BANK_PREVIEW +#: Number of rig slots in a bank. +BANK_SLOTS: int = gen.BANK_SLOTS + + +class BankPreviewField(Enum): + """Which of the three five-slot name groups on :data:`PAGE_BANK_PREVIEW`.""" + + #: Rig names (numbers ``BANK_RIG_NAME_BASE``..). + RIG_NAME = gen.BANK_RIG_NAME_BASE + #: Amp names (numbers ``BANK_AMP_NAME_BASE``..). + AMP_NAME = gen.BANK_AMP_NAME_BASE + #: Cabinet names (numbers ``BANK_CABINET_NAME_BASE``..). + CABINET_NAME = gen.BANK_CABINET_NAME_BASE + + @property + def base(self) -> int: + """First controller number of this group on :data:`PAGE_BANK_PREVIEW`.""" + return self.value + + +def bank_preview_address(field: BankPreviewField, slot: int) -> int: + """Flat NRPN address (``page * 128 + number``) of a bank-preview field for a + 1-based ``slot`` (1..:data:`BANK_SLOTS`). + + This is the read-on-demand address for + :func:`libkp.nrpn.request_extended_string`. + """ + slot = min(max(slot, 1), BANK_SLOTS) - 1 + return PAGE_BANK_PREVIEW * 128 + field.base + slot + + +def bank_preview_slot_field(number: int) -> tuple[BankPreviewField, int] | None: + """Reverse of :func:`bank_preview_address`: map a :data:`PAGE_BANK_PREVIEW` + number (0..14) to its ``(field, 0-based slot index)``, or ``None`` if out of + range.""" + for field in BankPreviewField: + if field.base <= number < field.base + BANK_SLOTS: + return field, number - field.base + return None + + def describe(page: int, number: int) -> str: """Render a ``"Page: Param"`` label for a page/number pair, falling back to hex.""" pname = page_name(page) diff --git a/python/src/libkp/state.py b/python/src/libkp/state.py index fe9cbc4..9681451 100644 --- a/python/src/libkp/state.py +++ b/python/src/libkp/state.py @@ -39,10 +39,13 @@ "Effect", "Tuner", "Output", + "BankSlot", + "Bank", "DeviceState", "DeviceEvent", "RigChanged", "StringTag", + "BankPreview", "EffectChanged", "ParamChanged", "Status", @@ -52,6 +55,7 @@ "TunerDeviance", "TunerNote", "RenderedString", + "CurrentPosition", "Connected", "Disconnected", "ApplyOutcome", @@ -209,6 +213,11 @@ def type_name(self) -> str | None: """The effect Type's human name, or ``None`` if unknown or unmapped.""" return None if self.kind is None else params.effect_type_name(self.kind) + @property + def category_name(self) -> str | None: + """The effect Type's category, or ``None`` if unknown or in no block.""" + return None if self.kind is None else params.effect_category_name(self.kind) + @property def is_empty(self) -> bool: """True if the slot holds no effect (Type == 0, "empty").""" @@ -239,9 +248,51 @@ class Output: #: Main Output Volume (NRPN ``0x7F/0``, 14-bit), once seen. main_volume: int | None = None - #: Monitor Output Volume (NRPN ``0x7F/2``, 14-bit), once seen. + #: Headphone Output Volume (NRPN ``0x7F/1``, 14-bit), once seen. Driven 1:1 by + #: the physical Master Volume knob. + headphone_volume: int | None = None + #: Monitor Output Volume (NRPN ``0x7F/2``, 14-bit), once seen. Driven 1:1 by + #: the physical Master Volume knob. monitor_volume: int | None = None + @property + def master_volume(self) -> int | None: + """The physical Master Volume knob's value. + + The knob is a potentiometer that drives Headphone (``0x7F/1``) and Monitor + (``0x7F/2``) 1:1 under the default output routing, so report Headphone, + falling back to Monitor. **Read-only**: the pot has no soft-takeover, so a + written value is authoritative only until the knob next moves. + """ + return self.headphone_volume if self.headphone_volume is not None else self.monitor_volume + + +@dataclass(slots=True) +class BankSlot: + """One bank slot's preview names — the identity of one of the current bank's + five rigs, as shown on the device's rig browser.""" + + #: Rig name in this slot (Bank Preview number 0..4), once seen. + rig_name: str | None = None + #: The rig's amp name (Bank Preview number 5..9), once seen. + amp_name: str | None = None + #: The rig's cabinet name (Bank Preview number 10..14), once seen. + cabinet_name: str | None = None + + +@dataclass(slots=True) +class Bank: + """The loaded bank's five-slot name preview (page ``0x96``). + + The device pushes the whole block on a bank change, and it is readable on + demand via :meth:`libkp.model.DeviceModel.refresh_bank`. ``slots[0]`` is rig + slot 1. + """ + + slots: list[BankSlot] = field( + default_factory=lambda: [BankSlot() for _ in range(gen.BANK_SLOTS)] + ) + # --------------------------------------------------------------------------- # Events @@ -265,6 +316,15 @@ class StringTag(DeviceEvent): number: int +@dataclass(frozen=True, slots=True) +class BankPreview(DeviceEvent): + """A bank-preview name was applied (page ``0x96``): one of the current bank's + rig / amp / cabinet names. Read :attr:`DeviceState.bank` for the new value.""" + + #: The Bank Preview number (0..14): 0–4 rig, 5–9 amp, 10–14 cabinet. + number: int + + @dataclass(frozen=True, slots=True) class EffectChanged(DeviceEvent): """An effect slot's Type, On/Off state or Mix changed.""" @@ -338,6 +398,20 @@ class RenderedString(DeviceEvent): text: str +@dataclass(frozen=True, slots=True) +class CurrentPosition(DeviceEvent): + """The device's current position was learned from the CBOR state-dump snapshot. + + Read :attr:`DeviceState.current_bank` and :attr:`DeviceState.current_rig_slot` + for the new values (both 0-based). + """ + + #: Current bank, 0-based, if the dump carried it. + bank: int | None + #: Current rig slot within the bank, 0-based, if the dump carried it. + slot: int | None + + @dataclass(frozen=True, slots=True) class Connected(DeviceEvent): """The model connected to a device.""" @@ -420,6 +494,32 @@ class DeviceState: tuner: Tuner = field(default_factory=Tuner) #: The global output volumes. output: Output = field(default_factory=Output) + #: The loaded bank's five-slot name preview (page ``0x96``). + bank: Bank = field(default_factory=Bank) + #: Current bank, 0-based, once known. Seeded from the CBOR state-dump + #: snapshot (:func:`libkp.cbor.fetch_state_snapshot`) via + #: :meth:`libkp.model.DeviceModel.set_current_position`, then kept live by + #: the Bank Select / Program Change pair the device sends on every rig change. + current_bank: int | None = None + #: Current rig slot within the bank, 0-based, once known. Same source as + #: :attr:`current_bank`; slot 0 is rig slot 1. + current_rig_slot: int | None = None + #: The high 7 bits of a rig index, held between the Bank Select that carries + #: them and the Program Change that completes the pair. + _pending_rig_index_msb: int | None = None + + @property + def current_rig_index(self) -> int | None: + """Flat, 0-based rig index, once both halves are known. + + ``current_bank * BANK_SLOTS + current_rig_slot`` -- the device's own + numbering, and the only address that can name a rig outside the current + bank. + """ + if self.current_bank is None or self.current_rig_slot is None: + return None + return self.current_bank * gen.BANK_SLOTS + self.current_rig_slot + #: Latest morph position (0–16383), once seen (NRPN ``0x00/0x0B``). morph: int | None = None #: The most recent realtime status / meter frame (the FAST lane). @@ -459,6 +559,12 @@ def apply(self, msg: bytes) -> ApplyOutcome: - ``$3C`` rendered-string reply → a transient ``RenderedString``. FAST. - ``$06`` extended and any other traffic → ignored. """ + # Channel-voice messages ride the same stream as the SysEx: the device + # announces every rig change as a Bank Select / Program Change pair. + outcome = self._apply_rig_index(msg) + if outcome is not None: + return outcome + parsed = NrpnHeader.parse(msg) if parsed is None: return ApplyOutcome.empty() @@ -483,6 +589,42 @@ def apply(self, msg: bytes) -> ApplyOutcome: # -- decode helpers ---------------------------------------------------- + def _apply_rig_index(self, msg: bytes) -> ApplyOutcome | None: + """Fold the device's position report into the tree. + + The device says where it is with two channel-voice messages: CC32 (Bank + Select LSB) carrying the high 7 bits, then a Program Change carrying the + low 7. Together they are a flat, 0-based rig index -- ``128 * msb + + program`` -- which divides by ``BANK_SLOTS`` into bank and slot. + Unlike the CBOR snapshot, a one-shot read at connect, this arrives on + every change, from the front panel as readily as from a controller. + + Returns ``None`` for anything that is not one of the two messages, so + NRPN parsing carries on. A Program Change with no Bank Select ahead of it + is ignored: half an index is not a position. + """ + if not msg: + return None + status = msg[0] & 0xF0 + if status == gen.CONTROL_CHANGE_STATUS: + if len(msg) < 3 or msg[1] != gen.CC_BANK_SELECT_LSB: + return None + self._pending_rig_index_msb = msg[2] & 0x7F + return ApplyOutcome.empty() + if status == gen.PROGRAM_CHANGE_STATUS: + msb = self._pending_rig_index_msb + if len(msg) < 2 or msb is None: + return None + self._pending_rig_index_msb = None + index = msb * 128 + (msg[1] & 0x7F) + bank, slot = divmod(index, gen.BANK_SLOTS) + if self.current_bank == bank and self.current_rig_slot == slot: + return ApplyOutcome.empty() + self.current_bank = bank + self.current_rig_slot = slot + return ApplyOutcome.slow([CurrentPosition(bank=bank, slot=slot)]) + return None + def _apply_string(self, number: int, vals: bytes) -> ApplyOutcome: """Route a ``$03`` page-0 string tag into the tree.""" return self._store_string_tag(number, _ascii_until_nul(vals)) @@ -500,10 +642,31 @@ def _apply_ext_string(self, msg: bytes) -> ApplyOutcome: return ApplyOutcome.empty() address, text = parsed page, number = divmod(address, 128) + if page == gen.PAGE_BANK_PREVIEW: + return self._store_bank_preview(number, text) if page != nrpn.PAGE_STRINGS: return ApplyOutcome.empty() return self._store_string_tag(number, text) + def _store_bank_preview(self, number: int, text: str) -> ApplyOutcome: + """Store one bank-preview name (page ``0x96``) into :attr:`bank`. + + The number selects the field (rig / amp / cabinet) and slot; an + out-of-range number is ignored. A stored value is a SLOW change. + """ + resolved = params.bank_preview_slot_field(number) + if resolved is None: + return ApplyOutcome.empty() + field_, slot = resolved + target = self.bank.slots[slot] + if field_ is params.BankPreviewField.RIG_NAME: + target.rig_name = text + elif field_ is params.BankPreviewField.AMP_NAME: + target.amp_name = text + else: + target.cabinet_name = text + return ApplyOutcome.slow([BankPreview(number=number)]) + def _store_string_tag(self, number: int, text: str) -> ApplyOutcome: """Store a decoded page-0 string tag into the matching field.""" target = _STRING_TAG_FIELDS.get(number) @@ -556,6 +719,9 @@ def _route_param(self, page: int, number: int, value: int) -> ApplyOutcome: if page == gen.SYSTEM_PAGE and number == gen.MAIN_VOLUME_NUMBER: self.output.main_volume = value return ApplyOutcome.slow([ParamChanged(page, number, value)]) + if page == gen.SYSTEM_PAGE and number == gen.HEADPHONE_VOLUME_NUMBER: + self.output.headphone_volume = value + return ApplyOutcome.slow([ParamChanged(page, number, value)]) if page == gen.SYSTEM_PAGE and number == gen.MONITOR_VOLUME_NUMBER: self.output.monitor_volume = value return ApplyOutcome.slow([ParamChanged(page, number, value)]) diff --git a/python/tests/test_conformance.py b/python/tests/test_conformance.py index 09aef31..2e19cb9 100644 --- a/python/tests/test_conformance.py +++ b/python/tests/test_conformance.py @@ -10,7 +10,7 @@ from conftest import VECTORS_DIR, vector from libkp import _generated as gen -from libkp import control, midi3, nrpn, params, protocol +from libkp import cbor, control, midi3, nrpn, params, protocol from libkp.state import DeviceState # --------------------------------------------------------------------------- @@ -19,13 +19,13 @@ def test_spec_version_matches(): - assert gen.SPEC_VERSION == "0.1.0" + assert gen.SPEC_VERSION == "0.5.0" def test_every_vector_file_is_covered(): """Guard against a new vector file landing without a test for it.""" present = {p.stem for p in VECTORS_DIR.glob("*.json")} - covered = {"u14", "discovery", "midi3", "nrpn", "controls", "params", "state"} + covered = {"u14", "discovery", "midi3", "nrpn", "controls", "params", "state", "cbor"} assert present == covered, f"uncovered vector files: {sorted(present - covered)}" @@ -256,6 +256,11 @@ def test_effect_type_name(case): assert params.effect_type_name(case["value"]) == case["name"] +@pytest.mark.parametrize("case", _PARAMS["effect_category_name"], ids=_ids(["value"])) +def test_effect_category_name(case): + assert params.effect_category_name(case["value"]) == case["name"] + + @pytest.mark.parametrize("case", _PARAMS["page_name"], ids=_ids(["page"])) def test_page_name(case): assert params.page_name(case["page"]) == case["name"] @@ -291,8 +296,29 @@ def _assert_state_expectations(state: DeviceState, expect: dict) -> None: assert state.morph == expect["morph"] if "tuner_note" in expect: assert state.tuner.note == expect["tuner_note"] + if "current_bank" in expect: + assert state.current_bank == expect["current_bank"] + if "current_rig_slot" in expect: + assert state.current_rig_slot == expect["current_rig_slot"] + if "current_rig_index" in expect: + assert state.current_rig_index == expect["current_rig_index"] if "main_volume" in expect: assert state.output.main_volume == expect["main_volume"] + if "headphone_volume" in expect: + assert state.output.headphone_volume == expect["headphone_volume"] + if "monitor_volume" in expect: + assert state.output.monitor_volume == expect["monitor_volume"] + if "master_volume" in expect: + assert state.output.master_volume == expect["master_volume"] + if "bank" in expect: + for entry in expect["bank"]: + slot = state.bank.slots[entry["slot"]] + if "rig_name" in entry: + assert slot.rig_name == entry["rig_name"] + if "amp_name" in entry: + assert slot.amp_name == entry["amp_name"] + if "cabinet_name" in entry: + assert slot.cabinet_name == entry["cabinet_name"] if "status_raw" in expect: assert list(state.status.raw) == expect["status_raw"] if "effect" in expect: @@ -313,3 +339,32 @@ def test_state_apply(case): for hex_message in case["messages"]: state.apply(bytes.fromhex(hex_message)) _assert_state_expectations(state, case["expect"]) + + +# --------------------------------------------------------------------------- +# cbor.json +# --------------------------------------------------------------------------- + +_CBOR = vector("cbor") + + +@pytest.mark.parametrize("case", _CBOR["param_write"], ids=lambda c: str(c["addr"])) +def test_cbor_param_write(case): + assert cbor.to_vec(cbor.param_write(case["addr"], case["value"])).hex() == case["hex"] + + +def test_cbor_state_dump_request(): + assert cbor.to_vec(cbor.state_dump_request()).hex() == _CBOR["state_dump_request"]["hex"] + + +@pytest.mark.parametrize("case", _CBOR["extract_snapshot"], ids=lambda c: c["name"]) +def test_cbor_extract_snapshot(case): + decoder = cbor.Decoder() + items = decoder.push(bytes.fromhex(case["stream_hex"])) + snap = cbor.extract_snapshot(items) + expect = case["expect"] + assert snap.current_bank == expect["current_bank"] + assert snap.current_rig_slot == expect["current_rig_slot"] + if "strings" in expect: + want = [(s["addr"], s["text"]) for s in expect["strings"]] + assert snap.strings == want diff --git a/python/tests/test_control.py b/python/tests/test_control.py index affade1..86fa531 100644 --- a/python/tests/test_control.py +++ b/python/tests/test_control.py @@ -26,7 +26,7 @@ def test_tuner_mode_open_is_cc31_value_1(): @pytest.mark.parametrize("n,controller", [(3, 52), (1, 50), (5, 54), (0, 50), (99, 54)]) def test_load_slot_maps_to_cc50_plus_and_clamps(n, controller): - assert c.LoadSlot(n).message(0) == bytes([0xB0, controller, 1]) + assert c.LoadSlot(n).message(0) == bytes([0xB0, controller, 1, 0xB0, controller, 0]) @pytest.mark.parametrize("n,controller", [(4, 78), (1, 75), (0, 75), (9, 78)]) @@ -57,8 +57,13 @@ def test_switch_variants_emit_one_or_zero(): assert c.DelayInfinity(True).message(0) == bytes([0xB0, 34, 1]) assert c.Freeze(True).message(0) == bytes([0xB0, 35, 1]) assert c.ToggleAllModules().message(0) == bytes([0xB0, 16, 1]) - assert c.Up().message(0) == bytes([0xB0, 48, 1]) - assert c.Down().message(0) == bytes([0xB0, 49, 1]) + + +def test_momentary_variants_press_then_release(): + """The device abandons a navigation whose button is never released.""" + assert c.Up().message(0) == bytes([0xB0, 48, 1, 0xB0, 48, 0]) + assert c.Down().message(0) == bytes([0xB0, 49, 1, 0xB0, 49, 0]) + assert c.Up().message(3) == bytes([0xB3, 48, 1, 0xB3, 48, 0]) def test_program_change_bytes(): diff --git a/python/tests/test_model.py b/python/tests/test_model.py index 6af790b..401e2f3 100644 --- a/python/tests/test_model.py +++ b/python/tests/test_model.py @@ -11,7 +11,7 @@ from libkp.errors import DisconnectedError, UnknownSlotError from libkp.model import DeviceModel from libkp.nrpn import PAGE_STRINGS, set_single, sysex, u14_split -from libkp.state import Connection, EffectChanged, Status, TempoBpm +from libkp.state import Connection, CurrentPosition, EffectChanged, Status, TempoBpm RIG_NAME = sysex(0x00, 0x00, 0x03, PAGE_STRINGS, 1, b"Test Rig\x00") REV_TYPE = set_single(0x00, 0x00, 0x3D, 0, 179) @@ -39,17 +39,19 @@ async def scenario(): async with FakeDevice() as device: model = await DeviceModel.connect("127.0.0.1", device.port) try: - await wait_for(lambda: len(device.received) >= 22) + await wait_for(lambda: len(device.received) >= 37) finally: await model.close() return device.received received = run(scenario()) - # Six string-tag requests, then a Type + On/Off request per effect slot. - assert len(received) == 6 + 16 - assert all(m[6] in (0x43, 0x41) for m in received), "sync must be read-only" + # Six string-tag requests, a Type + On/Off request per effect slot, then the + # 15 bank-preview extended-string requests (5 slots x rig/amp/cabinet). + assert len(received) == 6 + 16 + 15 + assert all(m[6] in (0x43, 0x41, 0x47) for m in received), "sync must be read-only" assert received[0] == bytes.fromhex("f0002033007f43000001f7") # Rig Name assert received[6][6] == 0x41 + assert received[22][6] == 0x47 # first bank-preview ext-string request def test_connect_can_skip_the_initial_sync(): @@ -257,15 +259,36 @@ async def scenario(): received = run(scenario()) assert received[:7] == [ bytes([0xB0, 30, 1]), - bytes([0xB0, 52, 1]), - bytes([0xB0, 48, 1]), - bytes([0xB0, 49, 1]), + # select_rig / rig_up / rig_down are momentary: press then release. + bytes([0xB0, 52, 1, 0xB0, 52, 0]), + bytes([0xB0, 48, 1, 0xB0, 48, 0]), + bytes([0xB0, 49, 1, 0xB0, 49, 0]), bytes([0xB0, 47, 3]), bytes([0xB0, 31, 1]), bytes([0xB0, 35, 0]), ] +def test_select_rig_index_sends_absolute_bank_then_slot(): + """Index 123 is bank 25 slot 4: CC47 value 24, then CC53 press/release.""" + + async def scenario(): + async with FakeDevice() as device: + model = await DeviceModel.connect("127.0.0.1", device.port, sync=False) + try: + await model.select_rig_index(123) + await wait_for(lambda: len(device.received) >= 2) + finally: + await model.close() + return device.received + + received = run(scenario()) + assert received[:2] == [ + bytes([0xB0, 47, 24]), + bytes([0xB0, 53, 1, 0xB0, 53, 0]), + ] + + def test_unknown_effect_slot_is_rejected(): async def scenario(): async with FakeDevice() as device: @@ -321,3 +344,38 @@ async def scenario(): return model.connected assert run(scenario()) is False + + +def test_set_current_position_folds_into_state_and_emits(): + async def scenario(): + async with FakeDevice() as device: + model = await DeviceModel.connect("127.0.0.1", device.port, sync=False) + events = model.events() + snapshots = model.subscribe() + try: + model.set_current_position(1, 2) + snapshot = await asyncio.wait_for(snapshots.get(), 2.0) + event = await asyncio.wait_for(events.get(), 2.0) + return model.state(), snapshot, event + finally: + await model.close() + + state, snapshot, event = run(scenario()) + assert (state.current_bank, state.current_rig_slot) == (1, 2) + assert (snapshot.current_bank, snapshot.current_rig_slot) == (1, 2) + assert event == CurrentPosition(bank=1, slot=2) + + +def test_set_current_position_only_overwrites_known_indices(): + async def scenario(): + async with FakeDevice() as device: + model = await DeviceModel.connect("127.0.0.1", device.port, sync=False) + try: + model.set_current_position(3, 4) + model.set_current_position(None, 0) # leave the bank untouched + return model.state() + finally: + await model.close() + + state = run(scenario()) + assert (state.current_bank, state.current_rig_slot) == (3, 0) diff --git a/python/tests/test_params.py b/python/tests/test_params.py index 2cf3c2e..489bb4c 100644 --- a/python/tests/test_params.py +++ b/python/tests/test_params.py @@ -43,6 +43,15 @@ def test_effect_types(): assert params.effect_type_name(5) is None +def test_effect_categories(): + assert params.effect_category_name(16) == "Wah" + assert params.effect_category_name(17) == "Shaper" + # A type with no name still resolves to its block. + assert params.effect_category_name(76) == "Modulation" + assert params.effect_category_name(0) is None + assert params.effect_category_name(300) is None + + def test_realtime_page_addresses(): assert params.page_name(0x7C) == "Realtime/Meters" assert params.param_name(0x7C, 0x4E) == "Tuner Strobe Segment (phase-low)" diff --git a/python/tests/test_state.py b/python/tests/test_state.py index 0acc6e2..91c7756 100644 --- a/python/tests/test_state.py +++ b/python/tests/test_state.py @@ -77,6 +77,7 @@ def test_effect_type_name_and_is_empty(): effect.kind = 179 assert not effect.is_empty assert effect.type_name == "Easy Reverb" + assert effect.category_name == "Reverb" @pytest.mark.parametrize( diff --git a/rust/src/control.rs b/rust/src/control.rs index 80a51eb..6c2fd2c 100644 --- a/rust/src/control.rs +++ b/rust/src/control.rs @@ -291,8 +291,20 @@ impl Control { /// is 2 bytes; [`Control::BankSelect`] is two Control Changes (CC0 then /// CC32) concatenated into 6 bytes. All controller/value bytes are masked /// to 7 bits and slot/button indices are clamped to their valid ranges. + /// + /// The navigation controls are **momentary**: the device treats value 1 as + /// the press and needs the matching value-0 release to complete the gesture. + /// Left held, it abandons the change and reloads the previous rig a couple + /// of seconds later. [`Control::Up`], [`Control::Down`] and + /// [`Control::LoadSlot`] therefore emit both halves as one 6-byte message. pub fn message(&self, channel: u8) -> Vec { let cc = |controller: u8, value: u8| control_change(channel, controller, value).to_vec(); + // A momentary press immediately followed by its release. + let tap = |controller: u8| { + let mut out = control_change(channel, controller, 1).to_vec(); + out.extend_from_slice(&control_change(channel, controller, 0)); + out + }; match *self { Control::WahPedal(v) => cc(CC_WAH_PEDAL, v), Control::PitchPedal(v) => cc(CC_PITCH_PEDAL, v), @@ -313,11 +325,11 @@ impl Control { Control::TapTempo => cc(CC_TAP_TEMPO, 1), Control::TunerMode(open) => cc(CC_TUNER_MODE, sw(open)), Control::BankPreselect(bank) => cc(CC_BANK_PRESELECT, bank), - Control::Up => cc(CC_UP, 1), - Control::Down => cc(CC_DOWN, 1), + Control::Up => tap(CC_UP), + Control::Down => tap(CC_DOWN), Control::LoadSlot(slot) => { let slot = slot.clamp(1, 5); - cc(CC_LOAD_SLOT_1 + (slot - 1), 1) + tap(CC_LOAD_SLOT_1 + (slot - 1)) } Control::EffectButton(n) => { let n = n.clamp(1, 4); @@ -359,12 +371,27 @@ mod tests { #[test] fn load_slot_maps_to_cc50_plus() { - assert_eq!(Control::LoadSlot(3).message(0), vec![0xB0, 52, 1]); - assert_eq!(Control::LoadSlot(1).message(0), vec![0xB0, 50, 1]); - assert_eq!(Control::LoadSlot(5).message(0), vec![0xB0, 54, 1]); + assert_eq!( + Control::LoadSlot(3).message(0), + vec![0xB0, 52, 1, 0xB0, 52, 0] + ); + assert_eq!( + Control::LoadSlot(1).message(0), + vec![0xB0, 50, 1, 0xB0, 50, 0] + ); + assert_eq!( + Control::LoadSlot(5).message(0), + vec![0xB0, 54, 1, 0xB0, 54, 0] + ); // Out-of-range clamps into 1..=5. - assert_eq!(Control::LoadSlot(0).message(0), vec![0xB0, 50, 1]); - assert_eq!(Control::LoadSlot(99).message(0), vec![0xB0, 54, 1]); + assert_eq!( + Control::LoadSlot(0).message(0), + vec![0xB0, 50, 1, 0xB0, 50, 0] + ); + assert_eq!( + Control::LoadSlot(99).message(0), + vec![0xB0, 54, 1, 0xB0, 54, 0] + ); } #[test] @@ -432,8 +459,14 @@ mod tests { assert_eq!(Control::DelayInfinity(true).message(0), vec![0xB0, 34, 1]); assert_eq!(Control::Freeze(true).message(0), vec![0xB0, 35, 1]); assert_eq!(Control::ToggleAllModules.message(0), vec![0xB0, 16, 1]); - assert_eq!(Control::Up.message(0), vec![0xB0, 48, 1]); - assert_eq!(Control::Down.message(0), vec![0xB0, 49, 1]); + } + + #[test] + fn momentary_variants_press_then_release() { + // The device abandons a navigation whose button is never released. + assert_eq!(Control::Up.message(0), vec![0xB0, 48, 1, 0xB0, 48, 0]); + assert_eq!(Control::Down.message(0), vec![0xB0, 49, 1, 0xB0, 49, 0]); + assert_eq!(Control::Up.message(3), vec![0xB3, 48, 1, 0xB3, 48, 0]); } #[test] diff --git a/rust/src/generated.rs b/rust/src/generated.rs index 5474897..e6097f3 100644 --- a/rust/src/generated.rs +++ b/rust/src/generated.rs @@ -2,12 +2,13 @@ #![allow(clippy::all)] #![cfg_attr(rustfmt, rustfmt::skip)] -pub const SPEC_VERSION: &str = "0.1.0"; +pub const SPEC_VERSION: &str = "0.5.0"; // Transport pub const PORT: u16 = 5727; pub const CONNECT_TIMEOUT_SECS: u64 = 5; pub const SOCKET_TIMEOUT_SECS: u64 = 15; +pub const CONNECTION_COOLDOWN_MS: u64 = 1000; // Discovery pub const DISCOVERY_HEADER: &str = "DSCV"; @@ -29,6 +30,17 @@ pub const PROTOCOL_REQUEST_RESPONSE: &str = "{2490272E-CD92-4DBA-AE32-E8AF37ED3B pub const PROTOCOL_CBOR_CONTROL: &str = "{774CDB9E-74ED-4740-AF09-AC96B3A69A11}"; pub const PROTOCOL_RESERVED: &str = "{77DB6B28-785E-4641-B840-42F0F06A11FC}"; +// CBOR channel +pub const CBOR_ITEM_TAG: u64 = 1; +pub const CBOR_SELECTOR_SINGLE: i64 = 1; +pub const CBOR_SELECTOR_MULTI: i64 = 2; +pub const CBOR_SELECTOR_STRING: i64 = 4; +pub const CBOR_FILLER_BYTE: u8 = 0xc0; +pub const STATE_DUMP_TRIGGER_ADDRESS: u32 = 102528; +pub const STATE_DUMP_TRIGGER_VALUE: i64 = 1; +pub const SENSITIVE_ADDRESSES: [u32; 2] = [200008, 200009]; +pub const REDACTED_PLACEHOLDER: &str = "[redacted]"; + // MIDI3 framing tags pub const MIDI3_TAG_CONTINUATION: u8 = 0x14; pub const MIDI3_TAG_FINAL_1: u8 = 0x15; @@ -88,7 +100,13 @@ pub const AMP_ON_NUMBER: u8 = 0x02; pub const GAIN_NUMBER: u8 = 0x04; pub const SYSTEM_PAGE: u8 = 0x7f; pub const MAIN_VOLUME_NUMBER: u8 = 0x00; +pub const HEADPHONE_VOLUME_NUMBER: u8 = 0x01; pub const MONITOR_VOLUME_NUMBER: u8 = 0x02; +pub const PAGE_BANK_PREVIEW: u8 = 0x96; +pub const BANK_SLOTS: usize = 5; +pub const BANK_RIG_NAME_BASE: u8 = 0x00; +pub const BANK_AMP_NAME_BASE: u8 = 0x05; +pub const BANK_CABINET_NAME_BASE: u8 = 0x0a; pub const PAGE_MORPH: u8 = 0x00; pub const MORPH_NUMBER: u8 = 0x0b; pub const PAGE_TUNER_NOTE: u8 = 0x7d; @@ -96,6 +114,8 @@ pub const TUNER_NOTE_NUMBER: u8 = 0x54; pub const TUNER_IN_TUNE_CENTER: u16 = 8192; pub const TUNER_IN_TUNE_WINDOW: u16 = 350; pub const METER_COUNT: usize = 11; +pub const CURRENT_BANK_ADDRESS: u32 = 100701; +pub const CURRENT_RIG_SLOT_ADDRESS: u32 = 100702; // Meter block pub const METER_PAGE: u8 = 0x7c; @@ -144,6 +164,7 @@ pub static PAGE_NAMES: &[(u8, &str)] = &[ (0x7c, "Realtime/Meters"), (0x7d, "Looper/Freeze"), (0x7f, "System/Global"), + (0x96, "Bank Preview"), ]; #[rustfmt::skip] @@ -305,6 +326,7 @@ pub static NON_EFFECT_PARAMS: &[(u8, u8, &str)] = &[ (0x0a, 11, "Tube Shape"), (0x0a, 12, "Tube Bias"), (0x0a, 15, "Direct Mix"), + (0x0a, 20, "Gain (smoothed follower)"), (0x0a, 21, "Bright Cap Intensity"), (0x0b, 4, "Bass"), (0x0b, 5, "Middle"), @@ -405,6 +427,21 @@ pub static NON_EFFECT_PARAMS: &[(u8, u8, &str)] = &[ (0x7f, 53, "Looper Location"), (0x7f, 59, "Aux >Mono"), (0x7f, 126, "Tuner Mode State"), + (0x96, 0, "Bank Rig Name"), + (0x96, 1, "Bank Rig Name"), + (0x96, 2, "Bank Rig Name"), + (0x96, 3, "Bank Rig Name"), + (0x96, 4, "Bank Rig Name"), + (0x96, 5, "Bank Amp Name"), + (0x96, 6, "Bank Amp Name"), + (0x96, 7, "Bank Amp Name"), + (0x96, 8, "Bank Amp Name"), + (0x96, 9, "Bank Amp Name"), + (0x96, 10, "Bank Cabinet Name"), + (0x96, 11, "Bank Cabinet Name"), + (0x96, 12, "Bank Cabinet Name"), + (0x96, 13, "Bank Cabinet Name"), + (0x96, 14, "Bank Cabinet Name"), ]; #[rustfmt::skip] @@ -533,6 +570,29 @@ pub static EFFECT_TYPES: &[(u16, &str)] = &[ (193, "Spring Reverb"), ]; +/// One category block of the effect Type value space: an inclusive range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EffectCategory { + pub min: u16, + pub max: u16, + pub name: &'static str, +} +#[rustfmt::skip] +pub static EFFECT_CATEGORIES: &[EffectCategory] = &[ + EffectCategory { min: 1, max: 16, name: "Wah" }, + EffectCategory { min: 17, max: 31, name: "Shaper" }, + EffectCategory { min: 32, max: 48, name: "Distortion" }, + EffectCategory { min: 49, max: 63, name: "Dynamics" }, + EffectCategory { min: 64, max: 79, name: "Modulation" }, + EffectCategory { min: 80, max: 95, name: "Phaser & Flanger" }, + EffectCategory { min: 96, max: 111, name: "Equalizer" }, + EffectCategory { min: 112, max: 120, name: "Booster" }, + EffectCategory { min: 121, max: 127, name: "Effect Loop" }, + EffectCategory { min: 128, max: 143, name: "Pitch" }, + EffectCategory { min: 144, max: 175, name: "Delay" }, + EffectCategory { min: 176, max: 207, name: "Reverb" }, +]; + pub const CC_WAH_PEDAL: u8 = 1; pub const CC_PITCH_PEDAL: u8 = 4; pub const CC_VOLUME_PEDAL: u8 = 7; diff --git a/rust/src/model.rs b/rust/src/model.rs index 25edc7c..30f4900 100644 --- a/rust/src/model.rs +++ b/rust/src/model.rs @@ -40,10 +40,13 @@ //! //! [`DeviceModel`]'s command methods split into two groups: //! -//! - **Parameters** (`set_*`) — settable values the device also reports back. -//! They go out as 14-bit NRPN `$01` Single Parameter Changes (via -//! [`crate::nrpn::set_single`]), so the device echoes the change on the same -//! stream the model ingests and [`DeviceModel::state`] stays consistent: +//! - **Parameters** (`set_*`) — settable values the device stores. They go out +//! as 14-bit NRPN `$01` Single Parameter Changes (via +//! [`crate::nrpn::set_single`]); the device applies the write silently and +//! does *not* echo it back on the stream, so follow a set with +//! [`request_param`](DeviceModel::request_param) when [`DeviceModel::state`] +//! should confirm the new value — the `$41` reply flows through normal +//! ingest. The setters are: //! [`set_gain`](DeviceModel::set_gain), //! [`set_rig_volume`](DeviceModel::set_rig_volume), //! [`set_main_volume`](DeviceModel::set_main_volume), @@ -78,7 +81,8 @@ use crate::midi3::{self, Unframer}; use crate::nrpn::{ self, FUNCTION_EXT_STRING_PARAM, FUNCTION_MULTI_PARAM, FUNCTION_RENDERED_STRING_REPLY, FUNCTION_SINGLE_PARAM, FUNCTION_STRING_PARAM, NrpnHeader, PAGE_STRINGS, multi_values, - request_rendered_string, request_single, request_string, set_single, u14, + request_extended_string, request_rendered_string, request_single, request_string, set_single, + u14, }; use crate::params::{self, EFFECT_PARAM_MIX, EFFECT_PARAM_STATE, EFFECT_PARAM_TYPE}; use crate::session::{PROTOCOL_MIDI3_STREAM, Session}; @@ -108,8 +112,13 @@ const GAIN_NUMBER: u8 = generated::GAIN_NUMBER; const SYSTEM_PAGE: u8 = generated::SYSTEM_PAGE; /// Main Output Volume number on [`SYSTEM_PAGE`] (14-bit). const MAIN_VOL_NUMBER: u8 = generated::MAIN_VOLUME_NUMBER; -/// Monitor Output Volume number on [`SYSTEM_PAGE`] (14-bit). +/// Headphone Output Volume number on [`SYSTEM_PAGE`] (14-bit); one half of the +/// physical Master Volume knob. +const HEADPHONE_VOL_NUMBER: u8 = generated::HEADPHONE_VOLUME_NUMBER; +/// Monitor Output Volume number on [`SYSTEM_PAGE`] (14-bit); the other half. const MONITOR_VOL_NUMBER: u8 = generated::MONITOR_VOLUME_NUMBER; +/// Bank Preview page (`0x96`): the loaded bank's five-slot name preview. +const PAGE_BANK_PREVIEW: u8 = generated::PAGE_BANK_PREVIEW; /// The maximum 14-bit NRPN value (0–16383). const NRPN_MAX: u16 = generated::FULL_SCALE; /// Morph-state page: page 0 is dual-use, morph lives at number [`MORPH_NUMBER`]. @@ -203,6 +212,12 @@ pub enum DeviceEvent { /// The string-tag number. number: u8, }, + /// A bank-preview name was applied (page `0x96`): one of the current bank's + /// rig / amp / cabinet names. Read [`DeviceState::bank`] for the new value. + BankPreview { + /// The Bank Preview number (0..14): 0–4 rig, 5–9 amp, 10–14 cabinet. + number: u8, + }, /// An effect slot's Type, On/Off state or Mix changed. EffectChanged { /// Index into [`crate::params::effect_slots`] (0 = A … 7 = REV). @@ -248,6 +263,15 @@ pub enum DeviceEvent { /// The rendered display text. text: String, }, + /// The device's current position was learned from the CBOR state-dump + /// snapshot. Read [`DeviceState::current_bank`] and + /// [`DeviceState::current_rig_slot`] for the new values (both 0-based). + CurrentPosition { + /// Current bank, 0-based, if the dump carried it. + bank: Option, + /// Current rig slot within the bank, 0-based, if the dump carried it. + slot: Option, + }, /// The model connected to a device. Connected, /// The device closed the connection. @@ -318,6 +342,11 @@ impl DeviceState { /// value's exact display text (see [`Self::apply_rendered_string`]). FAST. /// - `$06` extended and any other traffic → ignored. pub fn apply(&mut self, msg: &[u8]) -> ApplyOutcome { + // Channel-voice messages ride the same stream as the SysEx: the device + // announces every rig change as a Bank Select / Program Change pair. + if let Some(outcome) = self.apply_rig_index(msg) { + return outcome; + } // The extended-string ($07) address is 5×7-bit encoded and does not fit // the fixed `NrpnHeader` layout, so route it off the raw message first. if let Some((h, _)) = NrpnHeader::parse(msg) { @@ -343,6 +372,50 @@ impl DeviceState { } /// Route a `$03` page-0 string tag into the tree. + /// Fold the device's position report into the tree. + /// + /// The device says where it is with two channel-voice messages: CC32 (Bank + /// Select LSB) carrying the high 7 bits, then a Program Change carrying the + /// low 7. Together they are a flat, 0-based rig index — `128 * msb + program` + /// — which divides by [`generated::BANK_SLOTS`] into bank and slot. Unlike the CBOR + /// snapshot, a one-shot read at connect, this arrives on every change, from + /// the front panel as readily as from a controller. + /// + /// Returns `None` for anything that is not one of the two messages, so NRPN + /// parsing carries on. A Program Change with no Bank Select ahead of it is + /// ignored: half an index is not a position. + fn apply_rig_index(&mut self, msg: &[u8]) -> Option { + match msg.first()? & 0xF0 { + generated::CONTROL_CHANGE_STATUS => { + if msg.len() < 3 || msg[1] != generated::CC_BANK_SELECT_LSB { + return None; + } + self.pending_rig_index_msb = Some(msg[2] & 0x7F); + Some(ApplyOutcome::empty()) + } + generated::PROGRAM_CHANGE_STATUS => { + let msb = self.pending_rig_index_msb?; + if msg.len() < 2 { + return None; + } + self.pending_rig_index_msb = None; + let index = u16::from(msb) * 128 + u16::from(msg[1] & 0x7F); + let bank = index / generated::BANK_SLOTS as u16; + let slot = index % generated::BANK_SLOTS as u16; + if self.current_bank == Some(bank) && self.current_rig_slot == Some(slot) { + return Some(ApplyOutcome::empty()); + } + self.current_bank = Some(bank); + self.current_rig_slot = Some(slot); + Some(ApplyOutcome::slow(vec![DeviceEvent::CurrentPosition { + bank: Some(bank), + slot: Some(slot), + }])) + } + _ => None, + } + } + fn apply_string(&mut self, number: u8, vals: &[u8]) -> ApplyOutcome { let text: String = vals .iter() @@ -389,6 +462,9 @@ impl DeviceState { }; let page = (addr / 128) as u8; let number = (addr % 128) as u8; + if page == PAGE_BANK_PREVIEW { + return self.apply_bank_preview(number, text); + } if page != PAGE_STRINGS { return ApplyOutcome::empty(); } @@ -403,6 +479,22 @@ impl DeviceState { } } + /// Store one bank-preview name (page `0x96`) into [`DeviceState::bank`]. The + /// number selects the field (rig / amp / cabinet) and slot; an out-of-range + /// number is ignored. A stored value is a SLOW change. + fn apply_bank_preview(&mut self, number: u8, text: String) -> ApplyOutcome { + let Some((field, slot)) = params::bank_preview_slot_field(number) else { + return ApplyOutcome::empty(); + }; + let target = &mut self.bank.slots[slot]; + match field { + params::BankPreviewField::RigName => target.rig_name = Some(text), + params::BankPreviewField::AmpName => target.amp_name = Some(text), + params::BankPreviewField::CabinetName => target.cabinet_name = Some(text), + } + ApplyOutcome::slow(vec![DeviceEvent::BankPreview { number }]) + } + /// Route one decoded numeric address/value pair into the tree. Shared by /// `$01` single-param and each address of a `$02` rig-load dump. fn route_param(&mut self, page: u8, number: u8, value: u16) -> ApplyOutcome { @@ -451,6 +543,10 @@ impl DeviceState { self.output.main_volume = Some(value); return ApplyOutcome::slow(vec![generic(page, number, value)]); } + if page == SYSTEM_PAGE && number == HEADPHONE_VOL_NUMBER { + self.output.headphone_volume = Some(value); + return ApplyOutcome::slow(vec![generic(page, number, value)]); + } if page == SYSTEM_PAGE && number == MONITOR_VOL_NUMBER { self.output.monitor_volume = Some(value); return ApplyOutcome::slow(vec![generic(page, number, value)]); @@ -621,6 +717,7 @@ impl DeviceModel { }; // Read-only sync so the snapshot populates from the replies. let _ = model.refresh_rig().await; + let _ = model.refresh_bank().await; Ok(model) } @@ -732,6 +829,58 @@ impl DeviceModel { Ok(()) } + /// Request the current bank's five-slot name preview (rig / amp / cabinet + /// names) as extended strings (`$47`). The `$07` replies fold into + /// [`DeviceState::bank`]. Read-only: it changes nothing on the device. The + /// device also pushes this block unasked on a bank change, so a controller + /// need only call this once at connect. + pub async fn refresh_bank(&self) -> Result<(), CommandError> { + use params::BankPreviewField::{AmpName, CabinetName, RigName}; + for field in [RigName, AmpName, CabinetName] { + for slot in 1..=params::BANK_SLOTS as u8 { + let addr = params::bank_preview_address(field, slot); + self.enqueue(request_extended_string(PRODUCT, DEVICE, addr)) + .await?; + } + } + Ok(()) + } + + /// Fold a [`StateSnapshot`](crate::cbor::StateSnapshot)'s current bank and + /// rig slot into the state tree, emitting a + /// [`DeviceEvent::CurrentPosition`] and broadcasting a fresh snapshot. + /// + /// The MIDI3 stream never reports these indices; a controller learns them by + /// running [`StateSnapshot::fetch`](crate::cbor::StateSnapshot::fetch) over a + /// separate CBOR session — which the device wants done *before* this streaming + /// session opens, not concurrently — and applying the result here. Only the + /// `Some` fields overwrite; a `None` leaves the current value untouched. + pub fn set_current_position(&self, bank: Option, slot: Option) { + { + let mut st = write_state(&self.state); + if bank.is_some() { + st.current_bank = bank; + } + if slot.is_some() { + st.current_rig_slot = slot; + } + } + let _ = self + .events + .send(DeviceEvent::CurrentPosition { bank, slot }); + let _ = self.snapshots.send(read_state(&self.state).clone()); + } + + /// Request one numeric parameter's current value (function `$41`). The + /// device answers with a `$01` message on the same stream, which the ingest + /// task folds into the snapshot. Read-only: it changes nothing on the + /// device. This is the read-back to issue after a + /// [`set_param`](Self::set_param), which the device applies without echoing. + pub async fn request_param(&self, page: u8, number: u8) -> Result<(), CommandError> { + self.enqueue(request_single(PRODUCT, DEVICE, page, number)) + .await + } + /// Request a parameter value rendered to its exact display text (function /// `$7C`) — ask the device for the string a value shows on screen (e.g. /// `"5.2"`, `"120 BPM"`, `"<0.0>"`) instead of a generic percentage. @@ -784,6 +933,24 @@ impl DeviceModel { .await } + /// Load a rig by its flat, 0-based index — the device's own numbering, and + /// the only address that reaches a rig outside the current bank. + /// + /// Sent as the documented pair: the absolute bank preselect (CC47) followed + /// by the slot load (CC50–54) that commits it. The index divides by + /// [`generated::BANK_SLOTS`], so index 123 is bank 25, slot 4. + /// + /// Nothing here assumes how many banks a device has. Aim past the end and + /// the device simply stays where it is — and says so in the Bank Select / + /// Program Change report that follows, so + /// [`DeviceState::current_rig_index`](crate::state::DeviceState::current_rig_index) + /// always reflects where it actually landed, not where this aimed. + pub async fn select_rig_index(&self, index: u16) -> Result<(), CommandError> { + let slots = generated::BANK_SLOTS as u16; + self.bank(index / slots + 1).await?; + self.select_rig((index % slots) as u8 + 1).await + } + /// Tap the tempo (CC30). Mutating — advances the tap-tempo clock. pub async fn tap_tempo(&self) -> Result<(), CommandError> { self.send_control(Control::TapTempo).await @@ -1279,6 +1446,52 @@ mod tests { assert_eq!(st.apply(&msg), ApplyOutcome::empty()); } + #[test] + fn ext_string_populates_bank_preview() { + let mut st = DeviceState::new(); + // Bank Preview page 0x96: number 0 = slot 1 rig name, number 5 = slot 1 + // amp name, number 12 = slot 3 cabinet name. + let out = st.apply(&ext_string(PAGE_BANK_PREVIEW, 0, b"AC30")); + assert!(out.slow_changed); + assert_eq!(out.events, vec![DeviceEvent::BankPreview { number: 0 }]); + st.apply(&ext_string(PAGE_BANK_PREVIEW, 5, b"Vox AC30TB")); + st.apply(&ext_string(PAGE_BANK_PREVIEW, 12, b"2x12")); + assert_eq!(st.bank.slots[0].rig_name.as_deref(), Some("AC30")); + assert_eq!(st.bank.slots[0].amp_name.as_deref(), Some("Vox AC30TB")); + assert_eq!(st.bank.slots[2].cabinet_name.as_deref(), Some("2x12")); + // An out-of-range bank number is ignored. + assert_eq!( + st.apply(&ext_string(PAGE_BANK_PREVIEW, 15, b"x")), + ApplyOutcome::empty() + ); + } + + #[test] + fn headphone_volume_feeds_master() { + let mut st = DeviceState::new(); + assert_eq!(st.output.master_volume(), None); + // Monitor alone answers for master until headphone is seen. + st.apply(&set_single( + 0x00, + 0x00, + SYSTEM_PAGE, + MONITOR_VOL_NUMBER, + 5000, + )); + assert_eq!(st.output.master_volume(), Some(5000)); + let out = st.apply(&set_single( + 0x00, + 0x00, + SYSTEM_PAGE, + HEADPHONE_VOL_NUMBER, + 9000, + )); + assert!(out.slow_changed); + assert_eq!(st.output.headphone_volume, Some(9000)); + // Headphone wins once present. + assert_eq!(st.output.master_volume(), Some(9000)); + } + #[test] fn morph_sets_position() { let mut st = DeviceState::new(); diff --git a/rust/src/nrpn.rs b/rust/src/nrpn.rs index f843a8f..f2fd3c1 100644 --- a/rust/src/nrpn.rs +++ b/rust/src/nrpn.rs @@ -35,6 +35,9 @@ pub const FUNCTION_REQUEST_SINGLE: u8 = generated::FN_REQUEST_SINGLE; pub const FUNCTION_REQUEST_MULTI: u8 = generated::FN_REQUEST_MULTI; /// Request a string parameter (reply arrives as $03). pub const FUNCTION_REQUEST_STRING: u8 = generated::FN_REQUEST_STRING; +/// Request an extended string parameter (function $47): 5-byte encoded address, +/// reply arrives as $07 (or $03 below the 14-bit range). +pub const FUNCTION_REQUEST_EXT_STRING: u8 = generated::FN_REQUEST_EXT_STRING; /// Request a parameter value rendered to a string (reply arrives as $3C). pub const FUNCTION_REQUEST_RENDERED_STRING: u8 = generated::FN_REQUEST_RENDERED_STRING; /// Reply function for a rendered-string request ([`FUNCTION_REQUEST_RENDERED_STRING`]). @@ -254,6 +257,23 @@ pub fn parse_extended_string(msg: &[u8]) -> Option<(u32, String)> { Some((address, text)) } +/// Request an extended string parameter (function $47) at a flat address +/// (`page * 128 + number`). The device replies with a $07 extended string — or a +/// plain $03 when the address is below 16384. Read-only. +/// +/// Layout mirrors $07 minus the payload: +/// `F0 00 20 33 47 <5-byte address> F7`. This is how the +/// current bank's rig/amp/cabinet names are read on demand — the addresses are +/// [`crate::params::bank_preview_address`] over [`generated::PAGE_BANK_PREVIEW`]. +pub fn request_extended_string(product: u8, device: u8, address: u32) -> Vec { + let mut msg = vec![0xF0]; + msg.extend_from_slice(&MANUFACTURER_ID); + msg.extend_from_slice(&[product, device, FUNCTION_REQUEST_EXT_STRING, 0x00]); + msg.extend_from_slice(&ext_encode(address as u64, 5)); + msg.push(0xF7); + msg +} + /// A 3-byte MIDI Control Change on `channel` (0–15). pub fn control_change(channel: u8, controller: u8, value: u8) -> [u8; 3] { [ @@ -363,6 +383,21 @@ mod tests { ); } + #[test] + fn builds_request_extended_string() { + // Bank Preview page 0x96, rig-name slot 0: flat address 0x96*128 = 19200. + let addr = generated::PAGE_BANK_PREVIEW as u32 * 128; + let msg = request_extended_string(PRODUCT_PROFILER, DEVICE_OMNI, addr); + assert_eq!( + msg, + vec![ + 0xF0, 0x00, 0x20, 0x33, 0x00, 0x7F, 0x47, 0x00, 0x00, 0x00, 0x01, 0x16, 0x00, 0xF7 + ] + ); + // The 5-byte address round-trips back to the flat address. + assert_eq!(ext_decode(&msg[8..13]) as u32, addr); + } + #[test] fn builds_request_rendered_string() { // fn 7C, flags 00, page 0x3C, num 53 (Ducking), value 8192 (0x40 0x00). diff --git a/rust/src/params.rs b/rust/src/params.rs index d1e9bdd..6868ebe 100644 --- a/rust/src/params.rs +++ b/rust/src/params.rs @@ -79,6 +79,58 @@ pub fn page0_numeric_name(n: u8) -> Option<&'static str> { lookup_u8(generated::PAGE0_NUMERIC, n) } +/// Page holding the loaded bank's five-slot name preview (rig/amp/cabinet). +pub const PAGE_BANK_PREVIEW: u8 = generated::PAGE_BANK_PREVIEW; +/// Number of rig slots in a bank. +pub const BANK_SLOTS: usize = generated::BANK_SLOTS; + +/// Which of the three five-slot name groups on [`PAGE_BANK_PREVIEW`] to address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BankPreviewField { + /// Rig names (numbers [`generated::BANK_RIG_NAME_BASE`]..). + RigName, + /// Amp names (numbers [`generated::BANK_AMP_NAME_BASE`]..). + AmpName, + /// Cabinet names (numbers [`generated::BANK_CABINET_NAME_BASE`]..). + CabinetName, +} + +impl BankPreviewField { + /// First controller number of this group on [`PAGE_BANK_PREVIEW`]. + pub fn base(self) -> u8 { + match self { + BankPreviewField::RigName => generated::BANK_RIG_NAME_BASE, + BankPreviewField::AmpName => generated::BANK_AMP_NAME_BASE, + BankPreviewField::CabinetName => generated::BANK_CABINET_NAME_BASE, + } + } +} + +/// Flat NRPN address (`page * 128 + number`) of a bank-preview field for a +/// 1-based `slot` (1..=[`BANK_SLOTS`]). This is the read-on-demand address for +/// [`crate::nrpn::request_extended_string`]. +pub fn bank_preview_address(field: BankPreviewField, slot: u8) -> u32 { + let slot = slot.clamp(1, BANK_SLOTS as u8) - 1; + PAGE_BANK_PREVIEW as u32 * 128 + field.base() as u32 + slot as u32 +} + +/// Reverse of [`bank_preview_address`]: map a [`PAGE_BANK_PREVIEW`] number +/// (0..15) to its `(field, 0-based slot index)`, or `None` if out of range. +pub fn bank_preview_slot_field(number: u8) -> Option<(BankPreviewField, usize)> { + let slots = BANK_SLOTS as u8; + for field in [ + BankPreviewField::RigName, + BankPreviewField::AmpName, + BankPreviewField::CabinetName, + ] { + let base = field.base(); + if number >= base && number < base + slots { + return Some((field, usize::from(number - base))); + } + } + None +} + /// String-tag names on page 0 — function $03/$43 only. pub fn string_tag_name(n: u8) -> Option<&'static str> { lookup_u8(generated::STRING_TAGS, n) @@ -92,6 +144,16 @@ pub fn effect_type_name(value: u16) -> Option<&'static str> { .map(|i| generated::EFFECT_TYPES[i].1) } +/// The category of an effect Type value — the group of the device's type knob it +/// belongs to, derived from the block structure of Appendix B's value ranges. +/// `None` for 0 ("empty") and for values in no block. +pub fn effect_category_name(value: u16) -> Option<&'static str> { + generated::EFFECT_CATEGORIES + .iter() + .find(|c| value >= c.min && value <= c.max) + .map(|c| c.name) +} + /// Render a "Page: Param" label for a page/number pair, falling back to hex. pub fn describe(page: u8, number: u8) -> String { match (page_name(page), param_name(page, number)) { @@ -164,6 +226,16 @@ mod tests { assert_eq!(effect_type_name(0), Some("empty")); } + #[test] + fn effect_categories() { + assert_eq!(effect_category_name(16), Some("Wah")); + assert_eq!(effect_category_name(17), Some("Shaper")); + // A type with no name still resolves to its block. + assert_eq!(effect_category_name(76), Some("Modulation")); + assert_eq!(effect_category_name(0), None); + assert_eq!(effect_category_name(300), None); + } + #[test] fn realtime_page_addresses() { assert_eq!(page_name(0x7C), Some("Realtime/Meters")); @@ -207,4 +279,36 @@ mod tests { assert_eq!(function_name(0x7E), Some("beacon")); assert_eq!(function_name(0x55), None); } + + #[test] + fn bank_preview_addresses_and_names() { + assert_eq!(page_name(0x96), Some("Bank Preview")); + assert_eq!(param_name(0x96, 0), Some("Bank Rig Name")); + assert_eq!(param_name(0x96, 7), Some("Bank Amp Name")); + assert_eq!(param_name(0x96, 14), Some("Bank Cabinet Name")); + assert_eq!(param_name(0x96, 15), None); + // Slot 1 rig name is the flat page base; slot 5 cabinet is base+4. + assert_eq!(bank_preview_address(BankPreviewField::RigName, 1), 19200); + assert_eq!( + bank_preview_address(BankPreviewField::CabinetName, 5), + 19214 + ); + // Out-of-range slots clamp into 1..=BANK_SLOTS. + assert_eq!(bank_preview_address(BankPreviewField::AmpName, 0), 19205); + assert_eq!(bank_preview_address(BankPreviewField::AmpName, 9), 19209); + // The reverse map recovers (field, 0-based slot). + assert_eq!( + bank_preview_slot_field(3), + Some((BankPreviewField::RigName, 3)) + ); + assert_eq!( + bank_preview_slot_field(9), + Some((BankPreviewField::AmpName, 4)) + ); + assert_eq!( + bank_preview_slot_field(10), + Some((BankPreviewField::CabinetName, 0)) + ); + assert_eq!(bank_preview_slot_field(15), None); + } } diff --git a/rust/src/state.rs b/rust/src/state.rs index b0ecc41..3764384 100644 --- a/rust/src/state.rs +++ b/rust/src/state.rs @@ -87,6 +87,12 @@ impl Effect { params::effect_type_name(self.kind?) } + /// The effect Type's category via [`crate::params::effect_category_name`], + /// or `None` if the type is unknown or in no block. + pub fn category_name(&self) -> Option<&'static str> { + params::effect_category_name(self.kind?) + } + /// True if the slot holds no effect (Type == 0, "empty"). pub fn is_empty(&self) -> bool { self.kind == Some(0) @@ -119,10 +125,47 @@ impl Tuner { pub struct Output { /// Main Output Volume (NRPN `0x7F/0`, 14-bit), once seen. pub main_volume: Option, - /// Monitor Output Volume (NRPN `0x7F/2`, 14-bit), once seen. + /// Headphone Output Volume (NRPN `0x7F/1`, 14-bit), once seen. Driven 1:1 by + /// the physical Master Volume knob. + pub headphone_volume: Option, + /// Monitor Output Volume (NRPN `0x7F/2`, 14-bit), once seen. Driven 1:1 by + /// the physical Master Volume knob. pub monitor_volume: Option, } +impl Output { + /// The physical Master Volume knob's value. The knob is a potentiometer that + /// drives Headphone (`0x7F/1`) and Monitor (`0x7F/2`) 1:1 under the default + /// output routing, so report Headphone, falling back to Monitor. This is a + /// **read-only** readout: the pot has no soft-takeover, so a written value is + /// authoritative only until the knob next moves. + pub fn master_volume(&self) -> Option { + self.headphone_volume.or(self.monitor_volume) + } +} + +/// One bank slot's preview names — the identity of one of the current bank's +/// five rigs, as shown on the device's rig browser. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BankSlot { + /// Rig name in this slot (Bank Preview number 0..4), once seen. + pub rig_name: Option, + /// The rig's amp name (Bank Preview number 5..9), once seen. + pub amp_name: Option, + /// The rig's cabinet name (Bank Preview number 10..14), once seen. + pub cabinet_name: Option, +} + +/// The loaded bank's five-slot name preview (page `0x96`). The device pushes the +/// whole block on a bank change, and it is readable on demand via +/// [`DeviceModel::refresh_bank`](crate::model::DeviceModel::refresh_bank). Slot +/// index 0 is rig slot 1. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Bank { + /// The five preview slots, in slot order (index 0 = slot 1). + pub slots: [BankSlot; crate::generated::BANK_SLOTS], +} + /// The immutable device-state snapshot — the store's value type. /// /// A fresh, cheap-to-clone bag of plain data. The UI reads fields directly @@ -146,6 +189,20 @@ pub struct DeviceState { pub tuner: Tuner, /// The global output volumes. pub output: Output, + /// The loaded bank's five-slot name preview (page `0x96`). + pub bank: Bank, + /// Current bank, 0-based, once known. Seeded from the CBOR state-dump + /// snapshot ([`crate::cbor::StateSnapshot::fetch`]) via + /// [`DeviceModel::set_current_position`](crate::model::DeviceModel::set_current_position), + /// then kept live by the Bank Select / Program Change pair the device sends + /// on every rig change. + pub current_bank: Option, + /// Current rig slot within the bank, 0-based, once known. Same source as + /// [`current_bank`](Self::current_bank); slot 0 is rig slot 1. + pub current_rig_slot: Option, + /// The high 7 bits of a rig index, held between the Bank Select that carries + /// them and the Program Change that completes the pair. + pub(crate) pending_rig_index_msb: Option, /// Latest morph position (0–16383), once seen (NRPN `0x00/0x0B`). pub morph: Option, /// The most recent realtime status / meter frame (the FAST lane). @@ -153,6 +210,15 @@ pub struct DeviceState { } impl DeviceState { + /// The flat, 0-based rig index — `current_bank * BANK_SLOTS + + /// current_rig_slot` — once both halves are known. + /// + /// This is the device's own numbering, and the only address that can name a + /// rig outside the current bank. + pub fn current_rig_index(&self) -> Option { + Some(self.current_bank? * generated::BANK_SLOTS as u16 + self.current_rig_slot?) + } + /// A fresh, empty state: [`Connection::Disconnected`], no rig data, all eight /// effect slots seeded from [`crate::params::effect_slots`], zeroed meters. pub fn new() -> Self { @@ -174,6 +240,10 @@ impl DeviceState { effects, tuner: Tuner::default(), output: Output::default(), + bank: Bank::default(), + current_bank: None, + current_rig_slot: None, + pending_rig_index_msb: None, morph: None, status: RealtimeStatus::default(), } @@ -232,6 +302,7 @@ mod tests { e.kind = Some(179); assert!(!e.is_empty()); assert_eq!(e.type_name(), Some("Easy Reverb")); + assert_eq!(e.category_name(), Some("Reverb")); } #[test] diff --git a/rust/tests/conformance.rs b/rust/tests/conformance.rs index ec6341e..be4766f 100644 --- a/rust/tests/conformance.rs +++ b/rust/tests/conformance.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use serde_json::Value; +use libkp::cbor::{self, Decoder}; use libkp::control::{Control, ModuleSlot}; use libkp::midi3::{Unframer, frame}; use libkp::model::RealtimeStatus; @@ -94,7 +95,7 @@ fn opt_str(v: &Value) -> Option<&str> { #[test] fn spec_version_matches() { - assert_eq!(generated::SPEC_VERSION, "0.1.0"); + assert_eq!(generated::SPEC_VERSION, "0.5.0"); assert_eq!(libkp::SPEC_VERSION, generated::SPEC_VERSION); assert_eq!(PORT, 5727); } @@ -404,6 +405,15 @@ fn param_lookup_vectors() { ); } + for c in cases(&doc, "effect_category_name") { + let value = u16_of(c, "value"); + assert_eq!( + params::effect_category_name(value), + opt_str(&c["name"]), + "effect_category_name({value})" + ); + } + for c in cases(&doc, "page_name") { let page = u8_of(c, "page"); assert_eq!( @@ -512,6 +522,27 @@ fn state_apply_vectors() { "[{name}] tuner_note" ); } + if let Some(v) = expect.get("current_bank") { + assert_eq!( + state.current_bank, + Some(v.as_u64().unwrap() as u16), + "[{name}] current_bank" + ); + } + if let Some(v) = expect.get("current_rig_slot") { + assert_eq!( + state.current_rig_slot, + Some(v.as_u64().unwrap() as u16), + "[{name}] current_rig_slot" + ); + } + if let Some(v) = expect.get("current_rig_index") { + assert_eq!( + state.current_rig_index(), + Some(v.as_u64().unwrap() as u16), + "[{name}] current_rig_index" + ); + } if let Some(v) = expect.get("main_volume") { assert_eq!( state.output.main_volume, @@ -519,6 +550,106 @@ fn state_apply_vectors() { "[{name}] main_volume" ); } + if let Some(v) = expect.get("headphone_volume") { + assert_eq!( + state.output.headphone_volume, + Some(v.as_u64().unwrap() as u16), + "[{name}] headphone_volume" + ); + } + if let Some(v) = expect.get("monitor_volume") { + assert_eq!( + state.output.monitor_volume, + Some(v.as_u64().unwrap() as u16), + "[{name}] monitor_volume" + ); + } + if let Some(v) = expect.get("master_volume") { + assert_eq!( + state.output.master_volume(), + Some(v.as_u64().unwrap() as u16), + "[{name}] master_volume" + ); + } + if let Some(entries) = expect.get("bank").and_then(|v| v.as_array()) { + for e in entries { + let slot = e["slot"].as_u64().unwrap() as usize; + let bank_slot = &state.bank.slots[slot]; + if let Some(v) = e.get("rig_name") { + assert_eq!( + bank_slot.rig_name.as_deref(), + v.as_str(), + "[{name}] bank rig_name" + ); + } + if let Some(v) = e.get("amp_name") { + assert_eq!( + bank_slot.amp_name.as_deref(), + v.as_str(), + "[{name}] bank amp_name" + ); + } + if let Some(v) = e.get("cabinet_name") { + assert_eq!( + bank_slot.cabinet_name.as_deref(), + v.as_str(), + "[{name}] bank cabinet_name" + ); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// cbor.json +// --------------------------------------------------------------------------- + +#[test] +fn cbor_param_write_vectors() { + let doc = vector("cbor.json"); + for c in cases(&doc, "param_write") { + let addr = c["addr"].as_u64().unwrap() as u32; + let value = c["value"].as_i64().unwrap(); + assert_eq!( + hex(&cbor::to_vec(&cbor::param_write(addr, value))), + str_of(c, "hex"), + "param_write({addr}, {value})" + ); + } + assert_eq!( + hex(&cbor::to_vec(&cbor::state_dump_request())), + str_of(&doc["state_dump_request"], "hex"), + "state_dump_request" + ); +} + +#[test] +fn cbor_extract_snapshot_vectors() { + let doc = vector("cbor.json"); + for c in cases(&doc, "extract_snapshot") { + let name = str_of(c, "name"); + let mut decoder = Decoder::new(); + let items = decoder.push(&unhex(&str_of(c, "stream_hex"))); + let snap = cbor::extract_snapshot(&items); + let expect = &c["expect"]; + + let want_bank = expect["current_bank"].as_u64().map(|v| v as u16); + assert_eq!(snap.current_bank, want_bank, "[{name}] current_bank"); + let want_slot = expect["current_rig_slot"].as_u64().map(|v| v as u16); + assert_eq!( + snap.current_rig_slot, want_slot, + "[{name}] current_rig_slot" + ); + + if let Some(strings) = expect.get("strings").and_then(|v| v.as_array()) { + let got: Vec<(u32, String)> = snap.strings.clone(); + let want: Vec<(u32, String)> = strings + .iter() + .map(|s| (s["addr"].as_u64().unwrap() as u32, str_of(s, "text"))) + .collect(); + assert_eq!(got, want, "[{name}] strings"); + } } } diff --git a/spec/controls.toml b/spec/controls.toml index 9f3fa1b..0d83b2e 100644 --- a/spec/controls.toml +++ b/spec/controls.toml @@ -37,13 +37,13 @@ cc = [ { name = "delay_infinity", cc = 34, kind = "switch", note = "Delay Infinity" }, { name = "freeze", cc = 35, kind = "switch", note = "Delay + Reverb Freeze" }, { name = "bank_preselect", cc = 47, kind = "switch", note = "Bank/Performance preselect (value = bank - 1)" }, - { name = "up", cc = 48, kind = "switch", note = "Performance/Rig up" }, - { name = "down", cc = 49, kind = "switch", note = "Performance/Rig down" }, - { name = "load_slot_1", cc = 50, kind = "switch", note = "load Slot 1 (value 1)" }, - { name = "load_slot_2", cc = 51, kind = "switch", note = "load Slot 2 (value 1)" }, - { name = "load_slot_3", cc = 52, kind = "switch", note = "load Slot 3 (value 1)" }, - { name = "load_slot_4", cc = 53, kind = "switch", note = "load Slot 4 (value 1)" }, - { name = "load_slot_5", cc = 54, kind = "switch", note = "load Slot 5 (value 1)" }, + { name = "up", cc = 48, kind = "momentary", note = "Performance/Rig up (press 1, release 0)" }, + { name = "down", cc = 49, kind = "momentary", note = "Performance/Rig down (press 1, release 0)" }, + { name = "load_slot_1", cc = 50, kind = "momentary", note = "load Slot 1 (press 1, release 0)" }, + { name = "load_slot_2", cc = 51, kind = "momentary", note = "load Slot 2 (press 1, release 0)" }, + { name = "load_slot_3", cc = 52, kind = "momentary", note = "load Slot 3 (press 1, release 0)" }, + { name = "load_slot_4", cc = 53, kind = "momentary", note = "load Slot 4 (press 1, release 0)" }, + { name = "load_slot_5", cc = 54, kind = "momentary", note = "load Slot 5 (press 1, release 0)" }, { name = "effect_button_i", cc = 75, kind = "switch", note = "Effect Button I" }, { name = "effect_button_ii", cc = 76, kind = "switch", note = "Effect Button II" }, { name = "effect_button_iii", cc = 77, kind = "switch", note = "Effect Button III" }, diff --git a/spec/effect-types.toml b/spec/effect-types.toml index 427fafd..f9db639 100644 --- a/spec/effect-types.toml +++ b/spec/effect-types.toml @@ -91,3 +91,23 @@ effect_types = [ { value = 183, name = "Ionosphere Reverb" }, { value = 193, name = "Spring Reverb" }, ] + +# Effect Type value → category. Appendix B allocates Type values in 16-value +# blocks (a few split into half-blocks), one block per category of the device's +# type knob; the ranges below are inferred from that value-range structure +# rather than being printed as a table. Value 0 (empty) and any value outside +# every range has no category. +effect_categories = [ + { min = 1, max = 16, name = "Wah" }, + { min = 17, max = 31, name = "Shaper" }, + { min = 32, max = 48, name = "Distortion" }, + { min = 49, max = 63, name = "Dynamics" }, + { min = 64, max = 79, name = "Modulation" }, + { min = 80, max = 95, name = "Phaser & Flanger" }, + { min = 96, max = 111, name = "Equalizer" }, + { min = 112, max = 120, name = "Booster" }, + { min = 121, max = 127, name = "Effect Loop" }, + { min = 128, max = 143, name = "Pitch" }, + { min = 144, max = 175, name = "Delay" }, + { min = 176, max = 207, name = "Reverb" }, +] diff --git a/spec/parameters.toml b/spec/parameters.toml index e97222e..a841cd5 100644 --- a/spec/parameters.toml +++ b/spec/parameters.toml @@ -50,6 +50,7 @@ pages = [ { page = 0x7C, name = "Realtime/Meters" }, { page = 0x7D, name = "Looper/Freeze" }, { page = 0x7F, name = "System/Global" }, + { page = 0x96, name = "Bank Preview" }, ] # The eight effect slots in signal-chain order: (short name, address page). @@ -217,6 +218,10 @@ non_effect_params = [ { page = 0x0A, number = 11, name = "Tube Shape" }, { page = 0x0A, number = 12, name = "Tube Bias" }, { page = 0x0A, number = 15, name = "Direct Mix" }, + # Number 20 is the smoothed follower of Gain (number 4): the value the DSP is + # actually applying, which trails the base and converges on it. Display-only + # telemetry — a controller reads and writes number 4, not this. + { page = 0x0A, number = 20, name = "Gain (smoothed follower)" }, { page = 0x0A, number = 21, name = "Bright Cap Intensity" }, # Amplifier EQ (0x0B) { page = 0x0B, number = 4, name = "Bass" }, @@ -304,6 +309,14 @@ non_effect_params = [ param_ranges = [ { page = 0x76, start = 0, end = 11, name = "User Scale 1 Step" }, { page = 0x76, start = 12, end = 23, name = "User Scale 2 Step" }, + # Bank Preview (page 0x96): the loaded bank's five-slot preview. Numbers 0–4 + # are the five rig names, 5–9 their amps, 10–14 their cabinets. These are + # extended-string ($07 / request $47) addresses, since page 0x96 puts them + # above the 14-bit range. The device also pushes the whole block on a bank + # change. See the [bank_preview] section for the slot geometry. + { page = 0x96, start = 0, end = 4, name = "Bank Rig Name" }, + { page = 0x96, start = 5, end = 9, name = "Bank Amp Name" }, + { page = 0x96, start = 10, end = 14, name = "Bank Cabinet Name" }, ] # Page-0 string tags (function $03 / $43), Appendix C. @@ -364,7 +377,13 @@ amp_on_number = 0x02 gain_number = 0x04 system_page = 0x7F main_volume_number = 0x00 +headphone_volume_number = 0x01 monitor_volume_number = 0x02 +# The physical Master Volume knob is a potentiometer (the only absolute-position +# control on the device) that drives Headphone (0x7F/1) and Monitor (0x7F/2) 1:1 +# under the default output routing. It has no soft-takeover, so a written value +# is authoritative only until the pot next moves; treat master volume as a +# read-only readout. Main Output (0x7F/0) is independent of the knob. page_morph = 0x00 morph_number = 0x0B page_tuner_note = 0x7D @@ -372,3 +391,17 @@ tuner_note_number = 0x54 tuner_in_tune_center = 8192 tuner_in_tune_window = 350 meter_count = 11 +# Bank Preview (page 0x96): the loaded bank's five-slot name preview. The three +# groups of five consecutive numbers start at these bases (rig / amp / cabinet). +page_bank_preview = 0x96 +bank_slots = 5 +bank_rig_name_base = 0x00 +bank_amp_name_base = 0x05 +bank_cabinet_name_base = 0x0A +# The device's current position, as single extended addresses that appear in the +# CBOR state dump (docs/06). Both are 0-based: bank 0 slot 0 is the first rig of +# the first bank. The MIDI3 stream never reports these — the read-only name-match +# (loaded rig name vs. the bank preview) is the stream-only fallback for the slot, +# and cannot recover the bank number at all. +current_bank_address = 100701 # 0x1895D +current_rig_slot_address = 100702 # 0x1895E diff --git a/spec/protocol.toml b/spec/protocol.toml index e47cd47..c98e3b6 100644 --- a/spec/protocol.toml +++ b/spec/protocol.toml @@ -13,6 +13,14 @@ port = 5727 # TCP connect timeout and socket read timeout, in seconds. connect_timeout_secs = 5 socket_timeout_secs = 15 +# Minimum quiet gap between one TCP session closing and the next one opening, in +# milliseconds. The device refuses to greet — or resets — a session opened too +# soon after a prior socket closed (observed live: a session opened immediately +# after a close times out waiting for the greeting; ~1 s of spacing connects +# cleanly). An orchestrator that opens more than one session — e.g. the CBOR +# state-dump snapshot followed by a MIDI3 stream — must space them by at least +# this, and never run them concurrently. See docs/06. +connection_cooldown_ms = 1000 [discovery] # UDP broadcast discovery. The client broadcasts a fixed 34-byte poll; Profilers @@ -62,9 +70,38 @@ session_preamble_len = 8 # libkp; the CBOR channel is documented (docs/06) but not implemented. midi3_stream = "{369F50E7-750B-459A-BAEE-85ADD3F3798D}" # bidirectional MIDI3: meters + request/reply/control request_response = "{2490272E-CD92-4DBA-AE32-E8AF37ED3B0A}" # accepts; sends an 11-byte token on open -cbor_control = "{774CDB9E-74ED-4740-AF09-AC96B3A69A11}" # native CBOR (Protocol3) — experimental, not implemented +cbor_control = "{774CDB9E-74ED-4740-AF09-AC96B3A69A11}" # native CBOR (Protocol3): the state-dump snapshot route reserved = "{77DB6B28-785E-4641-B840-42F0F06A11FC}" # offered but rejects the handshake (-NO) +[cbor] +# The native control channel (protocol GUID `cbor_control`) completes the same +# handshake and 8-byte preamble as the MIDI3 stream, then carries a continuous +# sequence of bare CBOR items (RFC 8949) with no outer framing — the CBOR +# self-description supplies the boundaries. Every item is `tag(1)` wrapping a +# small integer array whose first element (after an optional leading negative +# source-flag word) selects the shape. See docs/06. +item_tag = 1 +selector_single = 1 # tag(1)([1, addr, value]) — one parameter +selector_multi = 2 # tag(1)([2, base, v0, v1, ...]) — a consecutive run from base +selector_string = 4 # tag(1)([4, addr, "text"]) — a string parameter +# Inter-item padding: bare 0xC0 (a tag(0) head with no content, not well-formed +# on its own) appears in runs between top-level items and must be skipped — +# except when the next head opens a text string, which marks a genuine tag(0) +# datetime rather than filler. +filler_byte = 0xC0 +# The device does NOT volunteer its full parameter state on connect; a passive +# session sees only live change events. Writing this single item asks for it: +# the device answers with the whole state as a burst of `selector_multi` and +# `selector_string` items. The write is non-mutating (it sets a status flag the +# device already carries) and applies to address 102528 = 1. +state_dump_trigger_address = 102528 +state_dump_trigger_value = 1 +# Addresses whose string values are the device's stored WiFi credentials +# (network name and passphrase). The state dump volunteers both in the clear; a +# reader must never surface them. See docs/06. +sensitive_addresses = [200008, 200009] +redacted_placeholder = "[redacted]" + [midi3] # The streaming protocol carries MIDI as 4-byte frames [tag][b0 b1 b2]. # 0x14 = continuation (3 valid bytes, more follow); 0x15/0x16/0x17 = final diff --git a/spec/vectors/controls.json b/spec/vectors/controls.json index 017246c..a71f3d4 100644 --- a/spec/vectors/controls.json +++ b/spec/vectors/controls.json @@ -1,5 +1,5 @@ { - "description": "Control op -> raw MIDI bytes. Each impl maps op names to its Control API; values are masked to 7 bits, load_slot clamps to 1..=5, effect_button to 1..=4.", + "description": "Control op -> raw MIDI bytes. Each impl maps op names to its Control API; values are masked to 7 bits, load_slot clamps to 1..=5, effect_button to 1..=4. Momentary controls (up, down, load_slot) emit a press (value 1) followed by a release (value 0) as one 6-byte message.", "cases": [ { "op": "gain", @@ -73,13 +73,13 @@ "op": "up", "params": {}, "channel": 0, - "hex": "b03001" + "hex": "b03001b03000" }, { "op": "down", "params": {}, "channel": 0, - "hex": "b03101" + "hex": "b03101b03100" }, { "op": "bank_preselect", @@ -135,7 +135,7 @@ "n": 3 }, "channel": 0, - "hex": "b03401" + "hex": "b03401b03400" }, { "op": "load_slot", @@ -143,7 +143,7 @@ "n": 0 }, "channel": 0, - "hex": "b03201" + "hex": "b03201b03200" }, { "op": "load_slot", @@ -151,7 +151,7 @@ "n": 99 }, "channel": 0, - "hex": "b03601" + "hex": "b03601b03600" }, { "op": "effect_button", diff --git a/spec/vectors/params.json b/spec/vectors/params.json index 113032a..0939d49 100644 --- a/spec/vectors/params.json +++ b/spec/vectors/params.json @@ -75,6 +75,72 @@ "name": null } ], + "effect_category_name": [ + { + "value": 0, + "name": null + }, + { + "value": 1, + "name": "Wah" + }, + { + "value": 16, + "name": "Wah" + }, + { + "value": 17, + "name": "Shaper" + }, + { + "value": 32, + "name": "Distortion" + }, + { + "value": 49, + "name": "Dynamics" + }, + { + "value": 76, + "name": "Modulation" + }, + { + "value": 89, + "name": "Phaser & Flanger" + }, + { + "value": 112, + "name": "Booster" + }, + { + "value": 121, + "name": "Effect Loop" + }, + { + "value": 129, + "name": "Pitch" + }, + { + "value": 161, + "name": "Delay" + }, + { + "value": 179, + "name": "Reverb" + }, + { + "value": 207, + "name": "Reverb" + }, + { + "value": 208, + "name": null + }, + { + "value": 300, + "name": null + } + ], "page_name": [ { "page": 124, diff --git a/spec/vectors/state.json b/spec/vectors/state.json index 0c0f9ee..1198109 100644 --- a/spec/vectors/state.json +++ b/spec/vectors/state.json @@ -80,6 +80,43 @@ "tuner_note": 9, "main_volume": 12000 } + }, + { + "name": "rig index, single byte", + "messages": [ + "b02000", + "c07b" + ], + "expect": { + "current_bank": 24, + "current_rig_slot": 3, + "current_rig_index": 123 + } + }, + { + "name": "rig index, two bytes", + "messages": [ + "b02001", + "c048" + ], + "expect": { + "current_bank": 40, + "current_rig_slot": 0, + "current_rig_index": 200 + } + }, + { + "name": "rig index ignores an unpaired program change", + "messages": [ + "b02000", + "c07b", + "c005" + ], + "expect": { + "current_bank": 24, + "current_rig_slot": 3, + "current_rig_index": 123 + } } ] } diff --git a/swift/Sources/LibKP/Control.swift b/swift/Sources/LibKP/Control.swift index b5bc74e..b0d6987 100644 --- a/swift/Sources/LibKP/Control.swift +++ b/swift/Sources/LibKP/Control.swift @@ -114,10 +114,18 @@ public enum Control: Equatable, Hashable, Sendable { /// is 2 bytes; ``bankSelect(msb:lsb:)`` is two Control Changes (CC0 then /// CC32) concatenated into 6 bytes. Controller/value bytes are masked to /// 7 bits and slot/button indices are clamped to their valid ranges. + /// + /// The navigation controls are **momentary**: the device reads value 1 as + /// the press and needs the matching value-0 release to complete the gesture. + /// Left held, it abandons the change and reloads the previous rig a couple + /// of seconds later. ``up``, ``down`` and ``loadSlot(_:)`` therefore emit + /// both halves as one 6-byte message. public func message(channel: UInt8 = 0) -> [UInt8] { func cc(_ controller: UInt8, _ value: UInt8) -> [UInt8] { Nrpn.controlChange(channel: channel, controller: controller, value: value) } + /// A momentary press immediately followed by its release. + func tap(_ controller: UInt8) -> [UInt8] { cc(controller, 1) + cc(controller, 0) } func sw(_ on: Bool) -> UInt8 { on ? 1 : 0 } switch self { @@ -140,11 +148,11 @@ public enum Control: Equatable, Hashable, Sendable { case .tapTempo: return cc(Generated.ccTapTempo, 1) case let .tunerMode(open): return cc(Generated.ccTunerMode, sw(open)) case let .bankPreselect(bank): return cc(Generated.ccBankPreselect, bank) - case .up: return cc(Generated.ccUp, 1) - case .down: return cc(Generated.ccDown, 1) + case .up: return tap(Generated.ccUp) + case .down: return tap(Generated.ccDown) case let .loadSlot(slot): let n = min(max(slot, 1), 5) - return cc(Generated.ccLoadSlot1 + (n - 1), 1) + return tap(Generated.ccLoadSlot1 + (n - 1)) case let .effectButton(button): let n = min(max(button, 1), 4) return cc(Generated.ccEffectButtonI + (n - 1), 1) diff --git a/swift/Sources/LibKP/DeviceModel.swift b/swift/Sources/LibKP/DeviceModel.swift index e158f6c..ed2e1c4 100644 --- a/swift/Sources/LibKP/DeviceModel.swift +++ b/swift/Sources/LibKP/DeviceModel.swift @@ -21,10 +21,11 @@ import Foundation /// /// Commands split into two groups: /// -/// - **Parameters** (`set*`) — settable values the device also reports back. -/// They go out as 14-bit NRPN `$01` Single Parameter Changes, so the device -/// echoes the change on the same stream the model ingests and the snapshot -/// stays consistent. +/// - **Parameters** (`set*`) — settable values the device stores. They go out as +/// 14-bit NRPN `$01` Single Parameter Changes; the device applies the write +/// silently and does *not* echo it back on the stream, so follow a set with +/// ``requestParam(page:number:)`` when the snapshot should confirm the new +/// value — the `$41` reply flows through normal ingest. /// - **Actions** (verbs) — momentary presses and live expression that carry no /// stored value. They go out as 7-bit Control Change messages via ``Control`` /// and are *not* reflected in state. @@ -75,6 +76,7 @@ public actor DeviceModel { let model = DeviceModel(session: session) await model.start(tail: outcome.responseTail) try? await model.refreshRig() + try? await model.refreshBank() return model } @@ -268,6 +270,39 @@ public actor DeviceModel { } } + /// Request the current bank's five-slot name preview (rig / amp / cabinet + /// names) as extended strings (`$47`). The `$07` replies fold into + /// ``DeviceState/bank``. Read-only: it changes nothing on the device. The + /// device also pushes this block unasked on a bank change, so a controller + /// need only call this once at connect. + public func refreshBank() async throws { + for field in Params.BankPreviewField.allCases { + for slot in 1...Params.bankSlots { + try await send( + Nrpn.requestExtendedString( + product: DeviceModel.product, device: DeviceModel.device, + address: Params.bankPreviewAddress(field, slot: slot) + )) + } + } + } + + /// Fold a ``StateSnapshot``'s current bank and rig slot into the state tree, + /// emitting a ``DeviceEvent/currentPosition(bank:slot:)`` and broadcasting a + /// fresh snapshot. + /// + /// The MIDI3 stream never reports these indices; a controller learns them by + /// running ``StateSnapshot/fetch(host:port:timeout:)`` over a separate CBOR + /// session — which the device wants done *before* this streaming session + /// opens, not concurrently — and applying the result here. Only the non-`nil` + /// fields overwrite; a `nil` leaves the current value untouched. + public func setCurrentPosition(bank: UInt16?, slot: UInt16?) { + if bank != nil { state.currentBank = bank } + if slot != nil { state.currentRigSlot = slot } + emit(.currentPosition(bank: bank, slot: slot)) + publishSnapshot() + } + /// Request one numeric parameter's current value (function `$41`). The /// device answers with a `$01` message on the same stream, which the ingest /// loop folds into the snapshot. Read-only. @@ -322,6 +357,7 @@ public actor DeviceModel { /// Select rig slot 1–5 in the current bank (CC50–54). public func selectRig(_ slot: UInt8) async throws { try await send(control: .loadSlot(slot)) } + /// Step to the next rig (CC48). public func rigUp() async throws { try await send(control: .up) } /// Step to the previous rig (CC49). @@ -330,6 +366,23 @@ public actor DeviceModel { public func bank(_ n: UInt16) async throws { try await send(control: .bankPreselect(UInt8(truncatingIfNeeded: max(n, 1) - 1))) } + + /// Load a rig by its flat, 0-based index — the device's own numbering, and + /// the only address that reaches a rig outside the current bank. + /// + /// Sent as the documented pair: the absolute bank preselect (CC47) followed + /// by the slot load (CC50–54) that commits it. The index divides by + /// ``Params/bankSlots``, so index 123 is bank 25, slot 4. + /// + /// Nothing here assumes how many banks a device has. Aim past the end and + /// the device simply stays where it is — and says so in the Bank Select / + /// Program Change report that follows, so ``DeviceState/currentRigIndex`` + /// always reflects where it actually landed, not where this aimed. + public func selectRigIndex(_ index: UInt16) async throws { + let slots = UInt16(Params.bankSlots) + try await bank(index / slots + 1) + try await selectRig(UInt8(index % slots) + 1) + } /// Tap the tempo (CC30). public func tapTempo() async throws { try await send(control: .tapTempo) } /// Open (`true`) or close (`false`) the tuner (CC31). diff --git a/swift/Sources/LibKP/Generated.swift b/swift/Sources/LibKP/Generated.swift index 9bb3ee8..f377969 100644 --- a/swift/Sources/LibKP/Generated.swift +++ b/swift/Sources/LibKP/Generated.swift @@ -3,10 +3,11 @@ import Foundation public enum Generated { - public static let specVersion: String = "0.1.0" + public static let specVersion: String = "0.5.0" public static let port: UInt16 = 5727 public static let connectTimeoutSecs: UInt64 = 5 public static let socketTimeoutSecs: UInt64 = 15 + public static let connectionCooldownMs: UInt64 = 1000 public static let discoveryHeader: String = "DSCV" public static let pollIntervalMs: UInt64 = 500 public static let pollMacPrefix: String = "MAC#" @@ -21,6 +22,15 @@ public enum Generated { public static let protocolRequestResponse: String = "{2490272E-CD92-4DBA-AE32-E8AF37ED3B0A}" public static let protocolCborControl: String = "{774CDB9E-74ED-4740-AF09-AC96B3A69A11}" public static let protocolReserved: String = "{77DB6B28-785E-4641-B840-42F0F06A11FC}" + public static let cborItemTag: UInt64 = 1 + public static let cborSelectorSingle: Int64 = 1 + public static let cborSelectorMulti: Int64 = 2 + public static let cborSelectorString: Int64 = 4 + public static let cborFillerByte: UInt8 = 0xc0 + public static let stateDumpTriggerAddress: UInt32 = 102528 + public static let stateDumpTriggerValue: Int64 = 1 + public static let sensitiveAddresses: [UInt32] = [200008, 200009] + public static let redactedPlaceholder: String = "[redacted]" public static let midi3TagContinuation: UInt8 = 0x14 public static let midi3TagFinal1: UInt8 = 0x15 public static let midi3TagFinal2: UInt8 = 0x16 @@ -69,7 +79,13 @@ public enum Generated { public static let gainNumber: UInt8 = 0x04 public static let systemPage: UInt8 = 0x7f public static let mainVolumeNumber: UInt8 = 0x00 + public static let headphoneVolumeNumber: UInt8 = 0x01 public static let monitorVolumeNumber: UInt8 = 0x02 + public static let pageBankPreview: UInt8 = 0x96 + public static let bankSlots: Int = 5 + public static let bankRigNameBase: UInt8 = 0x00 + public static let bankAmpNameBase: UInt8 = 0x05 + public static let bankCabinetNameBase: UInt8 = 0x0a public static let pageMorph: UInt8 = 0x00 public static let morphNumber: UInt8 = 0x0b public static let pageTunerNote: UInt8 = 0x7d @@ -77,6 +93,8 @@ public enum Generated { public static let tunerInTuneCenter: UInt16 = 8192 public static let tunerInTuneWindow: UInt16 = 350 public static let meterCount: Int = 11 + public static let currentBankAddress: UInt32 = 100701 + public static let currentRigSlotAddress: UInt32 = 100702 public static let meterPage: UInt8 = 0x7c public static let meterFirstNumber: UInt8 = 0x4e public static let meterUpdateRateHz: Int = 20 @@ -128,7 +146,7 @@ public enum Generated { public static let controlChangeStatus: UInt8 = 0xb0 public static let functionNames: [UInt8: String] = [0x01: "single-param", 0x02: "multi-param", 0x03: "string-param", 0x04: "blob", 0x06: "ext-param", 0x07: "ext-string-param", 0x08: "morphed-multi-param", 0x3c: "rendered-string-reply", 0x41: "request-single", 0x42: "request-multi", 0x43: "request-string", 0x47: "request-ext-string", 0x7c: "request-rendered-string", 0x7e: "beacon"] - public static let pageNames: [UInt8: String] = [0x00: "String Tags", 0x04: "Rig Settings", 0x05: "Fixed FX", 0x09: "Input Section", 0x0a: "Amplifier", 0x0b: "Amplifier EQ", 0x0c: "Cabinet", 0x32: "Effect A", 0x33: "Effect B", 0x34: "Effect C", 0x35: "Effect D", 0x38: "Effect X", 0x3a: "Effect MOD", 0x3c: "Effect DLY", 0x3d: "Effect REV", 0x76: "User Scales", 0x7c: "Realtime/Meters", 0x7d: "Looper/Freeze", 0x7f: "System/Global"] + public static let pageNames: [UInt8: String] = [0x00: "String Tags", 0x04: "Rig Settings", 0x05: "Fixed FX", 0x09: "Input Section", 0x0a: "Amplifier", 0x0b: "Amplifier EQ", 0x0c: "Cabinet", 0x32: "Effect A", 0x33: "Effect B", 0x34: "Effect C", 0x35: "Effect D", 0x38: "Effect X", 0x3a: "Effect MOD", 0x3c: "Effect DLY", 0x3d: "Effect REV", 0x76: "User Scales", 0x7c: "Realtime/Meters", 0x7d: "Looper/Freeze", 0x7f: "System/Global", 0x96: "Bank Preview"] public static let effectSlots: [(String, UInt8)] = [("A", 0x32), ("B", 0x33), ("C", 0x34), ("D", 0x35), ("X", 0x38), ("MOD", 0x3a), ("DLY", 0x3c), ("REV", 0x3d)] public static let effectParams: [UInt8: String] = [0: "Type", 3: "On/Off", 4: "Mix", 6: "Volume", 7: "Stereo", 8: "Wah Manual / Freq Shifter Delay Pitch", 9: "Wah Peak", 10: "Wah Pedal Range", 12: "Wah Pedal Mode", 13: "Wah Touch Attack / Fuzz Impedance LP", 14: "Wah Touch Release", 15: "Wah Touch Boost / Delay Cross Feedback", 16: "Distortion Drive / Reverb Formant Mix", 17: "Distortion Tone / Reverb Mid Frequency", 18: "Fuzz Octa / Compressor Intensity / Noise Gate Threshold / Auto Swell Compressor", 19: "Compressor Attack / Legacy Delay Bandwidth / Legacy Reverb Bandwidth", 20: "Fuzz Transistor Shape / Modulation Rate / Auto Swell / Widener Tune", 21: "Drive Definition / Fuzz Transistor Tone / Modulation Depth / Micro Pitch Detune / Double Tracker Looseness / Widener Intensity", 22: "Modulation Feedback / Formant Reverb Vowel", 23: "Drive Slim Down / Fuzz Definition / Modulation Crossover / Octaver Low Cut", 24: "Modulation Hyper Chorus Amount", 25: "Modulation Manual / Reverb Formant Offset / Spring Reverb Spectral Balance", 26: "Modulation Peak Spread / Wah Phaser Peak Spread / Reverb Formant Peak", 27: "Modulation Stages / Wah Phaser Stages / Legacy Reverb Room Size", 30: "Rotary Speed (Slow/Fast)", 31: "Rotary Distance", 32: "Rotary Low-High-Balance", 33: "Compressor Squash / Legacy Delay Frequency / Legacy Reverb Mid Frequency", 34: "Graphic EQ Gain 80 Hz", 35: "Graphic EQ Gain 160 Hz", 36: "Graphic EQ Gain 320 Hz", 37: "Graphic EQ Gain 640 Hz", 38: "Graphic EQ Gain 1250 Hz", 39: "Graphic EQ Gain 2500 Hz", 40: "Graphic EQ Gain 5000 Hz", 41: "Graphic EQ Gain 10000 Hz", 42: "Studio/Metal EQ / Metal DS Low Gain / Acoustic Sim Body", 43: "Studio EQ Low Frequency", 44: "Studio/Metal EQ / Metal DS High Gain / Acoustic Sim Sparkle", 45: "Studio EQ High Frequency", 46: "Studio EQ Mid1 / Metal EQ/DS Middle Gain / Acoustic Sim Bronze", 47: "Studio EQ Mid1 / Metal EQ/DS Middle Frequency", 48: "Studio EQ Mid1 Q-Factor", 49: "Studio EQ Mid2 Gain / Acoustic Sim Pickup", 50: "Studio EQ Mid2 Frequency", 51: "Studio EQ Mid2 Q-Factor", 52: "Wah Peak Range", 53: "Ducking", 54: "Mix 2 (Pitch/Octaver/Delay Serial/Crystal Mix / Space Intensity)", 55: "Voice Balance / Delay Balance", 56: "Voice 1 Pitch / Toe Pitch / Transpose Pitch / Quad Voice Pitch 4 / Crystal 1 Pitch", 57: "Voice 2 Pitch / Heel Pitch / Quad Voice Pitch 3 / Wah Formant Pitch Shift / Crystal 2 Pitch", 58: "Pitch Detune", 60: "Smooth Chords", 61: "Pure Tuning", 62: "Voice 1 Interval / Quad Voice 4 Interval", 63: "Voice 2 Interval / Quad Voice 3 Interval", 64: "Key", 65: "Formant Shift Freeze", 66: "Formant Shift Offset", 67: "Equalizer Low Cut", 68: "Equalizer High Cut / Reverb High Cut", 69: "Mix 3 (delay/reverb)", 70: "Mix Pre/Post", 71: "Delay 1 Time / Reverb Room Size / Reverb Attack / Spring Size", 72: "Delay 2 Time / Reverb Predelay Time", 73: "Delay 2 Ratio / Quad Delay 3 Ratio / Rate Flanger/Phaser Oneway", 74: "Quad Delay 2 Ratio / Delay Ratio Serial", 75: "Quad Delay 1 Ratio", 76: "Delay Note Value 1 / Quad Note Value 4", 77: "Delay Note Value 2 / Quad Note Value 3 / Reverb Predelay Note Value", 78: "Quad Note Value 2 / Note Value Serial", 79: "Quad Note Value 1", 80: "To Tempo / Equalizer Steep Low", 81: "Delay Volume 4", 82: "Delay Volume 3", 83: "Delay Volume 2", 84: "Delay Volume 1", 85: "Delay Panorama 4", 86: "Delay Panorama 3", 87: "Delay Panorama 2", 88: "Delay Panorama 1", 89: "Voice Pitch 2 / Crystal Pitch", 90: "Voice Pitch 1", 91: "Voice 3 Interval", 92: "Voice 4 Interval", 93: "Delay Feedback 1 / Reverb Decay Time", 94: "Infinity Feedback", 95: "Infinity", 96: "Feedback 2/Serial / Reverb Low Boost / Echo Reverb Feedback / Ionosphere Buildup", 97: "Delay Feedback Sync", 98: "Delay Low Cut / Reverb Low Decay/Damp", 99: "Delay High Cut / Reverb High Decay/Damp / Fuzz True Impedance", 100: "Delay Cut More / Equalizer Steep High / Full OC HP/LP / Effect Loop (Stage)", 101: "Modulation (delay/reverb)", 102: "Delay Chorus", 103: "Delay Flutter Intensity / Reverb Modulation", 104: "Delay Flutter Rate / Reverb Early Diffusion / Spring Dripstone", 105: "Delay Grit / Reverb Brass / Spring Distortion (Dwell)", 106: "Reverse Mix", 107: "Input Swell", 108: "Smear", 109: "Ducking Pre/Post"] public static let nonEffectParams: [NonEffectKey: String] = [ @@ -170,6 +188,7 @@ public enum Generated { NonEffectKey(0x0a, 11): "Tube Shape", NonEffectKey(0x0a, 12): "Tube Bias", NonEffectKey(0x0a, 15): "Direct Mix", + NonEffectKey(0x0a, 20): "Gain (smoothed follower)", NonEffectKey(0x0a, 21): "Bright Cap Intensity", NonEffectKey(0x0b, 4): "Bass", NonEffectKey(0x0b, 5): "Middle", @@ -270,6 +289,21 @@ public enum Generated { NonEffectKey(0x7f, 53): "Looper Location", NonEffectKey(0x7f, 59): "Aux >Mono", NonEffectKey(0x7f, 126): "Tuner Mode State", + NonEffectKey(0x96, 0): "Bank Rig Name", + NonEffectKey(0x96, 1): "Bank Rig Name", + NonEffectKey(0x96, 2): "Bank Rig Name", + NonEffectKey(0x96, 3): "Bank Rig Name", + NonEffectKey(0x96, 4): "Bank Rig Name", + NonEffectKey(0x96, 5): "Bank Amp Name", + NonEffectKey(0x96, 6): "Bank Amp Name", + NonEffectKey(0x96, 7): "Bank Amp Name", + NonEffectKey(0x96, 8): "Bank Amp Name", + NonEffectKey(0x96, 9): "Bank Amp Name", + NonEffectKey(0x96, 10): "Bank Cabinet Name", + NonEffectKey(0x96, 11): "Bank Cabinet Name", + NonEffectKey(0x96, 12): "Bank Cabinet Name", + NonEffectKey(0x96, 13): "Bank Cabinet Name", + NonEffectKey(0x96, 14): "Bank Cabinet Name", ] public static let stringTags: [UInt8: String] = [1: "Rig Name", 2: "Rig Author", 3: "Rig Creation Date", 4: "Rig Comment", 10: "Amp Name", 11: "Amp Author", 14: "Amp Location", 15: "Amp Manufacturer", 16: "Amp Comment", 18: "Amp Model", 19: "Amp Channel", 20: "Pickup Type", 21: "Year of Production", 32: "Cabinet Name", 33: "Cabinet Author", 36: "Cabinet Location", 37: "Cabinet Manufacturer", 38: "Microphone Model", 39: "Cabinet Comment", 40: "Microphone Position", 41: "Speaker Configuration", 42: "Cabinet Model", 44: "Speaker Manufacturer", 45: "Speaker Model"] public static let page0Numeric: [UInt8: String] = [0x0b: "Morph State"] @@ -364,6 +398,20 @@ public enum Generated { 183: "Ionosphere Reverb", 193: "Spring Reverb", ] + public static let effectCategories: [EffectCategory] = [ + EffectCategory(min: 1, max: 16, name: "Wah"), + EffectCategory(min: 17, max: 31, name: "Shaper"), + EffectCategory(min: 32, max: 48, name: "Distortion"), + EffectCategory(min: 49, max: 63, name: "Dynamics"), + EffectCategory(min: 64, max: 79, name: "Modulation"), + EffectCategory(min: 80, max: 95, name: "Phaser & Flanger"), + EffectCategory(min: 96, max: 111, name: "Equalizer"), + EffectCategory(min: 112, max: 120, name: "Booster"), + EffectCategory(min: 121, max: 127, name: "Effect Loop"), + EffectCategory(min: 128, max: 143, name: "Pitch"), + EffectCategory(min: 144, max: 175, name: "Delay"), + EffectCategory(min: 176, max: 207, name: "Reverb"), + ] public static let slotEnableCc: [String: UInt8] = ["A": 17, "B": 18, "C": 19, "D": 20, "X": 22, "MOD": 24, "DLY": 27, "REV": 29] public static let meterFields: [MeterField] = [ MeterField(index: 0, number: 78, id: "strobe_seg_low", name: "Tuner Strobe Segment (phase-low)", render: "strobe"), @@ -394,3 +442,9 @@ public struct MeterField { public let render: String } +public struct EffectCategory { + public let min: UInt16 + public let max: UInt16 + public let name: String +} + diff --git a/swift/Sources/LibKP/Nrpn.swift b/swift/Sources/LibKP/Nrpn.swift index 098ab01..69f4f25 100644 --- a/swift/Sources/LibKP/Nrpn.swift +++ b/swift/Sources/LibKP/Nrpn.swift @@ -86,6 +86,25 @@ public enum Nrpn { ) } + /// Request an extended string parameter (function `$47`) at a flat address + /// (`page * 128 + number`). The device replies with a `$07` extended string — + /// or a plain `$03` when the address is below 16384. Read-only. + /// + /// Layout mirrors `$07` minus the payload: + /// `F0 00 20 33 47 <5-byte address> F7`. This is how + /// the current bank's rig/amp/cabinet names are read on demand — the + /// addresses come from ``Params/bankPreviewAddress(_:slot:)``. + public static func requestExtendedString( + product: UInt8, device: UInt8, address: UInt32 + ) -> [UInt8] { + var msg: [UInt8] = [0xF0] + msg.append(contentsOf: Generated.manufacturerId) + msg.append(contentsOf: [product, device, Generated.fnRequestExtString, 0x00]) + msg.append(contentsOf: extEncode(UInt64(address), count: 5)) + msg.append(0xF7) + return msg + } + /// Request a parameter value rendered to a string (function `$7C`). The /// device replies with a `$3C` message carrying the rendered ASCII (e.g. /// `<0.0>`). Read-only, but costly in device CPU. diff --git a/swift/Sources/LibKP/Params.swift b/swift/Sources/LibKP/Params.swift index 8883d9e..a6f3e67 100644 --- a/swift/Sources/LibKP/Params.swift +++ b/swift/Sources/LibKP/Params.swift @@ -5,6 +5,7 @@ import Foundation // than in the generated file, which stays a pure data drop. extension NonEffectKey: @unchecked Sendable {} extension MeterField: @unchecked Sendable {} +extension EffectCategory: @unchecked Sendable {} /// Offline name lookups over the generated spec tables. /// @@ -80,12 +81,65 @@ public enum Params { Generated.effectTypes[value] } + /// The category of an effect Type value — the group of the device's type + /// knob it belongs to, derived from the block structure of Appendix B's + /// value ranges. `nil` for 0 ("empty") and for values in no block. + public static func effectCategoryName(_ value: UInt16) -> String? { + Generated.effectCategories.first { value >= $0.min && value <= $0.max }?.name + } + /// Page 0 is dual-use: string tags via `$03`/`$43`, but *numeric* params via /// `$01`/`$41` (PySwitch reads the morph state this way). public static func page0NumericName(_ number: UInt8) -> String? { Generated.page0Numeric[number] } + // MARK: - Bank preview + + /// Page holding the loaded bank's five-slot name preview (rig/amp/cabinet). + public static let pageBankPreview: UInt8 = Generated.pageBankPreview + /// Number of rig slots in a bank. + public static let bankSlots: Int = Generated.bankSlots + + /// Which of the three five-slot name groups on ``pageBankPreview`` to address. + public enum BankPreviewField: CaseIterable, Sendable { + /// Rig names (numbers `bankRigNameBase`..). + case rigName + /// Amp names (numbers `bankAmpNameBase`..). + case ampName + /// Cabinet names (numbers `bankCabinetNameBase`..). + case cabinetName + + /// First controller number of this group on ``pageBankPreview``. + public var base: UInt8 { + switch self { + case .rigName: return Generated.bankRigNameBase + case .ampName: return Generated.bankAmpNameBase + case .cabinetName: return Generated.bankCabinetNameBase + } + } + } + + /// Flat NRPN address (`page * 128 + number`) of a bank-preview field for a + /// 1-based `slot` (1...``bankSlots``). The read-on-demand address for + /// ``Nrpn/requestExtendedString(product:device:address:)``. + public static func bankPreviewAddress(_ field: BankPreviewField, slot: Int) -> UInt32 { + let clamped = min(max(slot, 1), bankSlots) - 1 + return UInt32(pageBankPreview) * 128 + UInt32(field.base) + UInt32(clamped) + } + + /// Reverse of ``bankPreviewAddress(_:slot:)``: map a ``pageBankPreview`` + /// number (0..<15) to its `(field, 0-based slot index)`, or `nil` if out of + /// range. + public static func bankPreviewSlotField(_ number: UInt8) -> (BankPreviewField, Int)? { + let slots = UInt8(bankSlots) + for field in BankPreviewField.allCases + where number >= field.base && number < field.base + slots { + return (field, Int(number - field.base)) + } + return nil + } + /// Render a "Page: Param" label for a page/number pair, falling back to hex. public static func describe(page: UInt8, number: UInt8) -> String { switch (pageName(page), paramName(page: page, number: number)) { diff --git a/swift/Sources/LibKP/State.swift b/swift/Sources/LibKP/State.swift index 028ccb6..f934357 100644 --- a/swift/Sources/LibKP/State.swift +++ b/swift/Sources/LibKP/State.swift @@ -75,6 +75,9 @@ public struct Effect: Sendable, Equatable { /// The effect Type's human name, or `nil` if unknown or unmapped. public var typeName: String? { kind.flatMap { Params.effectTypeName($0) } } + /// The effect Type's category, or `nil` if unknown or in no block. + public var categoryName: String? { kind.flatMap { Params.effectCategoryName($0) } } + /// True if the slot holds no effect (Type == 0, "empty"). public var isEmpty: Bool { kind == 0 } } @@ -101,10 +104,46 @@ public struct Tuner: Sendable, Equatable { public struct Output: Sendable, Equatable { /// Main Output Volume (NRPN `0x7F/0`, 14-bit), once seen. public var mainVolume: UInt16? - /// Monitor Output Volume (NRPN `0x7F/2`, 14-bit), once seen. + /// Headphone Output Volume (NRPN `0x7F/1`, 14-bit), once seen. Driven 1:1 by + /// the physical Master Volume knob. + public var headphoneVolume: UInt16? + /// Monitor Output Volume (NRPN `0x7F/2`, 14-bit), once seen. Driven 1:1 by + /// the physical Master Volume knob. public var monitorVolume: UInt16? public init() {} + + /// The physical Master Volume knob's value. The knob is a potentiometer that + /// drives Headphone (`0x7F/1`) and Monitor (`0x7F/2`) 1:1 under the default + /// output routing, so report Headphone, falling back to Monitor. This is a + /// **read-only** readout: the pot has no soft-takeover, so a written value is + /// authoritative only until the knob next moves. + public var masterVolume: UInt16? { headphoneVolume ?? monitorVolume } +} + +/// One bank slot's preview names — the identity of one of the current bank's +/// five rigs, as shown on the device's rig browser. +public struct BankSlot: Sendable, Equatable { + /// Rig name in this slot (Bank Preview number 0…4), once seen. + public var rigName: String? + /// The rig's amp name (Bank Preview number 5…9), once seen. + public var ampName: String? + /// The rig's cabinet name (Bank Preview number 10…14), once seen. + public var cabinetName: String? + + public init() {} +} + +/// The loaded bank's five-slot name preview (page `0x96`). The device pushes the +/// whole block on a bank change, and it is readable on demand via +/// ``DeviceModel/refreshBank()``. `slots[0]` is rig slot 1. +public struct Bank: Sendable, Equatable { + /// The five preview slots, in slot order (index 0 = slot 1). + public var slots: [BankSlot] + + public init() { + slots = Array(repeating: BankSlot(), count: Generated.bankSlots) + } } /// A decoded realtime status / meter frame: the eleven 14-bit values the stream @@ -154,6 +193,10 @@ public enum DeviceEvent: Sendable, Equatable { case rigChanged /// A page-0 string tag was applied (1 = Rig Name, 10 = Amp Name, …). case stringTag(number: UInt8) + /// A bank-preview name was applied (page `0x96`): one of the current bank's + /// rig / amp / cabinet names. Read ``DeviceState/bank`` for the new value. + /// `number` is the Bank Preview number (0…14): 0–4 rig, 5–9 amp, 10–14 cab. + case bankPreview(number: UInt8) /// An effect slot's Type, On/Off or Mix changed. `slot` indexes /// ``Params/effectSlots`` (0 = A … 7 = REV). case effectChanged(slot: Int) @@ -174,6 +217,10 @@ public enum DeviceEvent: Sendable, Equatable { /// A rendered-string reply arrived (`$3C`), carrying a value's exact display /// text. Transient — not stored in the snapshot tree. case renderedString(page: UInt8, number: UInt8, value: UInt16, text: String) + /// The device's current position was learned from the CBOR state-dump + /// snapshot. Read ``DeviceState/currentBank`` and + /// ``DeviceState/currentRigSlot`` for the new values (both 0-based). + case currentPosition(bank: UInt16?, slot: UInt16?) /// The model connected to a device. case connected /// The device closed the connection. @@ -230,6 +277,26 @@ public struct DeviceState: Sendable, Equatable { public var tuner: Tuner /// The global output volumes. public var output: Output + /// The loaded bank's five-slot name preview (page `0x96`). + public var bank: Bank + /// Current bank, 0-based, once known. Seeded from the CBOR state-dump + /// snapshot (``StateSnapshot/fetch(host:port:timeout:)``) via + /// ``DeviceModel/setCurrentPosition(bank:slot:)``, then kept live by the + /// Bank Select / Program Change pair the device sends on every rig change. + public var currentBank: UInt16? + /// Current rig slot within the bank, 0-based, once known. Same source as + /// ``currentBank``; slot 0 is rig slot 1. + public var currentRigSlot: UInt16? + /// The high 7 bits of a rig index, held between the Bank Select that carries + /// them and the Program Change that completes the pair. + private var pendingRigIndexMsb: UInt8? + /// The flat, 0-based rig index — `currentBank * bankSlots + currentRigSlot` + /// — once both halves are known. This is the device's own numbering, and the + /// only address that can name a rig outside the current bank. + public var currentRigIndex: UInt16? { + guard let currentBank, let currentRigSlot else { return nil } + return currentBank * UInt16(Params.bankSlots) + currentRigSlot + } /// Latest morph position (0–16383), once seen. public var morph: UInt16? /// The most recent realtime status / meter frame (the FAST lane). @@ -245,6 +312,9 @@ public struct DeviceState: Sendable, Equatable { effects = Params.effectSlots.map { Effect(slot: $0.name, page: $0.page) } tuner = Tuner() output = Output() + bank = Bank() + currentBank = nil + currentRigSlot = nil morph = nil status = RealtimeStatus() } @@ -275,6 +345,9 @@ extension DeviceState { /// - `$06` extended and any other traffic → ignored. @discardableResult public mutating func apply(_ msg: [UInt8]) -> ApplyOutcome { + // Channel-voice messages ride the same stream as the SysEx: the device + // announces every rig change as a Bank Select / Program Change pair. + if let outcome = applyRigIndex(msg) { return outcome } guard let (header, values) = NrpnHeader.parse(msg) else { return .empty } // The extended-string ($07) address is 5×7-bit encoded and does not fit @@ -297,6 +370,40 @@ extension DeviceState { } } + /// Fold the device's position report into the tree. + /// + /// The device says where it is with two channel-voice messages: CC32 (Bank + /// Select LSB) carrying the high 7 bits, then a Program Change carrying the + /// low 7. Together they are a flat, 0-based rig index — `128 * msb + program` + /// — which divides by ``Params/bankSlots`` into bank and slot. Unlike the + /// CBOR snapshot, which is a one-shot read at connect, this arrives on every + /// change, from the front panel as readily as from a controller. + /// + /// Returns `nil` for anything that is not one of the two messages, so NRPN + /// parsing carries on. A Program Change with no Bank Select ahead of it is + /// ignored: half an index is not a position. + private mutating func applyRigIndex(_ msg: [UInt8]) -> ApplyOutcome? { + guard let status = msg.first else { return nil } + switch status & 0xF0 { + case Generated.controlChangeStatus: + guard msg.count >= 3, msg[1] == Generated.ccBankSelectLsb else { return nil } + pendingRigIndexMsb = msg[2] & 0x7F + return .empty + case Generated.programChangeStatus: + guard msg.count >= 2, let msb = pendingRigIndexMsb else { return nil } + pendingRigIndexMsb = nil + let index = UInt16(msb) * 128 + UInt16(msg[1] & 0x7F) + let bank = index / UInt16(Params.bankSlots) + let slot = index % UInt16(Params.bankSlots) + guard currentBank != bank || currentRigSlot != slot else { return .empty } + currentBank = bank + currentRigSlot = slot + return .slow([.currentPosition(bank: bank, slot: slot)]) + default: + return nil + } + } + /// Route a `$03` page-0 string tag into the tree. private mutating func applyString(number: UInt8, values: [UInt8]) -> ApplyOutcome { let tracked = applyStringTag(number: number, text: Fmt.textUntilNul(values[...])) @@ -330,6 +437,9 @@ extension DeviceState { guard let (address, text) = Nrpn.parseExtendedString(msg) else { return .empty } let page = UInt8(truncatingIfNeeded: address / 128) let number = UInt8(truncatingIfNeeded: address % 128) + if page == Generated.pageBankPreview { + return applyBankPreview(number: number, text: text) + } guard page == Generated.pageStrings else { return .empty } let tracked = applyStringTag(number: number, text: text) var events: [DeviceEvent] = [.stringTag(number: number)] @@ -337,6 +447,19 @@ extension DeviceState { return ApplyOutcome(events: events, slowChanged: tracked) } + /// Store one bank-preview name (page `0x96`) into ``bank``. The number selects + /// the field (rig / amp / cabinet) and slot; an out-of-range number is + /// ignored. A stored value is a SLOW change. + private mutating func applyBankPreview(number: UInt8, text: String) -> ApplyOutcome { + guard let (field, slot) = Params.bankPreviewSlotField(number) else { return .empty } + switch field { + case .rigName: bank.slots[slot].rigName = text + case .ampName: bank.slots[slot].ampName = text + case .cabinetName: bank.slots[slot].cabinetName = text + } + return .slow([.bankPreview(number: number)]) + } + /// Route one decoded numeric address/value pair into the tree. Shared by /// `$01` single-param and each address of a `$02` rig-load dump. private mutating func routeParam(page: UInt8, number: UInt8, value: UInt16) -> ApplyOutcome { @@ -384,6 +507,10 @@ extension DeviceState { output.mainVolume = value return .slow([.paramChanged(page: page, number: number, value: value)]) } + if page == Generated.systemPage && number == Generated.headphoneVolumeNumber { + output.headphoneVolume = value + return .slow([.paramChanged(page: page, number: number, value: value)]) + } if page == Generated.systemPage && number == Generated.monitorVolumeNumber { output.monitorVolume = value return .slow([.paramChanged(page: page, number: number, value: value)]) diff --git a/swift/Tests/LibKPTests/ConformanceTests.swift b/swift/Tests/LibKPTests/ConformanceTests.swift index e067ff3..f080e19 100644 --- a/swift/Tests/LibKPTests/ConformanceTests.swift +++ b/swift/Tests/LibKPTests/ConformanceTests.swift @@ -9,7 +9,7 @@ final class ConformanceTests: XCTestCase { // MARK: - Suite bookkeeping func testSpecVersionMatches() { - XCTAssertEqual(Generated.specVersion, "0.1.0") + XCTAssertEqual(Generated.specVersion, "0.5.0") } /// Every vector file must be covered by a test in this class, so a new file @@ -17,7 +17,7 @@ final class ConformanceTests: XCTestCase { func testEveryVectorFileIsCovered() throws { let covered: Set = [ "u14.json", "discovery.json", "midi3.json", "nrpn.json", - "controls.json", "params.json", "state.json", + "controls.json", "params.json", "state.json", "cbor.json", ] let present = Set(try Fixtures.vectorFiles().map(\.lastPathComponent)) XCTAssertEqual(present, covered, "spec/vectors changed; update the conformance suite") @@ -292,6 +292,13 @@ final class ConformanceTests: XCTestCase { ) } + for entry in vector.cases("effect_category_name") { + XCTAssertEqual( + Params.effectCategoryName(entry.u16("value")), entry.optionalString("name"), + "effect_category_name(\(entry.int("value")))" + ) + } + for entry in vector.cases("page_name") { XCTAssertEqual( Params.pageName(entry.u8("page")), entry.optionalString("name"), @@ -371,6 +378,16 @@ final class ConformanceTests: XCTestCase { XCTAssertEqual( state.tuner.deviance, deviance.uint16Value, "\(caseName): tuner_deviance") } + if let bank = expect["current_bank"] as? NSNumber { + XCTAssertEqual(state.currentBank, bank.uint16Value, "\(caseName): current_bank") + } + if let slot = expect["current_rig_slot"] as? NSNumber { + XCTAssertEqual(state.currentRigSlot, slot.uint16Value, "\(caseName): current_rig_slot") + } + if let index = expect["current_rig_index"] as? NSNumber { + XCTAssertEqual( + state.currentRigIndex, index.uint16Value, "\(caseName): current_rig_index") + } if let mainVolume = expect["main_volume"] as? NSNumber { XCTAssertEqual( state.output.mainVolume, mainVolume.uint16Value, "\(caseName): main_volume") @@ -380,6 +397,29 @@ final class ConformanceTests: XCTestCase { state.output.monitorVolume, monitorVolume.uint16Value, "\(caseName): monitor_volume" ) } + if let headphoneVolume = expect["headphone_volume"] as? NSNumber { + XCTAssertEqual( + state.output.headphoneVolume, headphoneVolume.uint16Value, + "\(caseName): headphone_volume") + } + if let masterVolume = expect["master_volume"] as? NSNumber { + XCTAssertEqual( + state.output.masterVolume, masterVolume.uint16Value, "\(caseName): master_volume") + } + if let bank = expect["bank"] as? [[String: Any]] { + for entry in bank { + let slot = state.bank.slots[entry.int("slot")] + if let rigName = entry["rig_name"] as? String { + XCTAssertEqual(slot.rigName, rigName, "\(caseName): bank rig_name") + } + if let ampName = entry["amp_name"] as? String { + XCTAssertEqual(slot.ampName, ampName, "\(caseName): bank amp_name") + } + if let cabName = entry["cabinet_name"] as? String { + XCTAssertEqual(slot.cabinetName, cabName, "\(caseName): bank cabinet_name") + } + } + } if let raw = expect["status_raw"] as? [NSNumber] { XCTAssertEqual(state.status.raw, raw.map(\.uint16Value), "\(caseName): status_raw") } @@ -404,6 +444,59 @@ final class ConformanceTests: XCTestCase { } } + // MARK: - cbor.json + + func testCborVectors() throws { + let vector = try Fixtures.vector("cbor") + + for entry in vector.cases("param_write") { + let addr = UInt32(entry.int("addr")) + let value = Int64(entry.int("value")) + XCTAssertEqual( + Fmt.hex(Cbor.encode(Cbor.paramWrite(addr: addr, value: value))), + entry.string("hex"), "param_write(\(addr), \(value))") + } + if let dump = vector["state_dump_request"] as? [String: Any] { + XCTAssertEqual( + Fmt.hex(Cbor.encode(Cbor.stateDumpRequest())), dump.string("hex"), + "state_dump_request") + } else { + XCTFail("cbor.json has no state_dump_request") + } + + for entry in vector.cases("extract_snapshot") { + let name = entry.string("name") + var decoder = CBORDecoder() + let items = decoder.push(try hex(entry.string("stream_hex"))) + let snap = Cbor.extractSnapshot(items) + guard let expect = entry["expect"] as? [String: Any] else { + XCTFail("case \"\(name)\" has no expect block") + continue + } + if let bank = expect["current_bank"] as? NSNumber { + XCTAssertEqual(snap.currentBank, bank.uint16Value, "\(name): current_bank") + } else { + XCTAssertNil(snap.currentBank, "\(name): current_bank") + } + if let slot = expect["current_rig_slot"] as? NSNumber { + XCTAssertEqual(snap.currentRigSlot, slot.uint16Value, "\(name): current_rig_slot") + } else { + XCTAssertNil(snap.currentRigSlot, "\(name): current_rig_slot") + } + if let strings = expect["strings"] as? [[String: Any]] { + XCTAssertEqual(snap.strings.count, strings.count, "\(name): strings count") + for (i, expected) in strings.enumerated() where i < snap.strings.count { + XCTAssertEqual( + snap.strings[i].address, UInt32(expected.int("addr")), + "\(name): strings[\(i)].addr") + XCTAssertEqual( + snap.strings[i].text, expected.string("text"), "\(name): strings[\(i)].text" + ) + } + } + } + } + // MARK: - Helpers private func hex(_ string: String) throws -> [UInt8] { diff --git a/swift/Tests/LibKPTests/UnitTests.swift b/swift/Tests/LibKPTests/UnitTests.swift index fd662fc..2e94217 100644 --- a/swift/Tests/LibKPTests/UnitTests.swift +++ b/swift/Tests/LibKPTests/UnitTests.swift @@ -233,6 +233,18 @@ final class NrpnTests: XCTestCase { } } + func testRequestExtendedString() { + // Bank Preview page 0x96, rig-name slot 1: flat address 0x96 * 128 = 19200. + let address = Params.bankPreviewAddress(.rigName, slot: 1) + XCTAssertEqual(address, 19200) + let message = Nrpn.requestExtendedString( + product: Generated.productProfiler, device: Generated.deviceOmni, address: address) + XCTAssertEqual( + message, + [0xF0, 0x00, 0x20, 0x33, 0x00, 0x7F, 0x47, 0x00, 0x00, 0x00, 0x01, 0x16, 0x00, 0xF7]) + XCTAssertEqual(UInt32(Nrpn.extDecode(Array(message[8..<13]))), address) + } + func testParseExtendedString() { var message: [UInt8] = [0xF0, 0x00, 0x20, 0x33, 0x02, 0x00, 0x07, 0x00] message += Nrpn.extEncode(1, count: 5) @@ -291,8 +303,8 @@ final class ControlTests: XCTestCase { func testMomentaryActions() { XCTAssertEqual(Control.tapTempo.message(), [0xB0, 30, 1]) XCTAssertEqual(Control.toggleAllModules.message(), [0xB0, 16, 1]) - XCTAssertEqual(Control.up.message(), [0xB0, 48, 1]) - XCTAssertEqual(Control.down.message(), [0xB0, 49, 1]) + XCTAssertEqual(Control.up.message(), [0xB0, 48, 1, 0xB0, 48, 0]) + XCTAssertEqual(Control.down.message(), [0xB0, 49, 1, 0xB0, 49, 0]) } func testSwitchVariantsEmitOneOrZero() { @@ -307,11 +319,11 @@ final class ControlTests: XCTestCase { } func testLoadSlotClampsIntoRange() { - XCTAssertEqual(Control.loadSlot(3).message(), [0xB0, 52, 1]) - XCTAssertEqual(Control.loadSlot(1).message(), [0xB0, 50, 1]) - XCTAssertEqual(Control.loadSlot(5).message(), [0xB0, 54, 1]) - XCTAssertEqual(Control.loadSlot(0).message(), [0xB0, 50, 1]) - XCTAssertEqual(Control.loadSlot(99).message(), [0xB0, 54, 1]) + XCTAssertEqual(Control.loadSlot(3).message(), [0xB0, 52, 1, 0xB0, 52, 0]) + XCTAssertEqual(Control.loadSlot(1).message(), [0xB0, 50, 1, 0xB0, 50, 0]) + XCTAssertEqual(Control.loadSlot(5).message(), [0xB0, 54, 1, 0xB0, 54, 0]) + XCTAssertEqual(Control.loadSlot(0).message(), [0xB0, 50, 1, 0xB0, 50, 0]) + XCTAssertEqual(Control.loadSlot(99).message(), [0xB0, 54, 1, 0xB0, 54, 0]) } func testEffectButtonClampsIntoRange() { @@ -388,12 +400,40 @@ final class ParamsTests: XCTestCase { XCTAssertFalse(Params.isEffectPage(0x04)) } + func testBankPreviewAddressesAndNames() { + XCTAssertEqual(Params.pageName(0x96), "Bank Preview") + XCTAssertEqual(Params.paramName(page: 0x96, number: 0), "Bank Rig Name") + XCTAssertEqual(Params.paramName(page: 0x96, number: 7), "Bank Amp Name") + XCTAssertEqual(Params.paramName(page: 0x96, number: 14), "Bank Cabinet Name") + XCTAssertNil(Params.paramName(page: 0x96, number: 15)) + XCTAssertEqual(Params.bankPreviewAddress(.rigName, slot: 1), 19200) + XCTAssertEqual(Params.bankPreviewAddress(.cabinetName, slot: 5), 19214) + // Out-of-range slots clamp into 1...bankSlots. + XCTAssertEqual(Params.bankPreviewAddress(.ampName, slot: 0), 19205) + XCTAssertEqual(Params.bankPreviewAddress(.ampName, slot: 9), 19209) + // The reverse map recovers (field, 0-based slot). + XCTAssertTrue(Params.bankPreviewSlotField(3).map { $0.0 == .rigName && $0.1 == 3 } ?? false) + XCTAssertTrue(Params.bankPreviewSlotField(9).map { $0.0 == .ampName && $0.1 == 4 } ?? false) + XCTAssertTrue( + Params.bankPreviewSlotField(10).map { $0.0 == .cabinetName && $0.1 == 0 } ?? false) + XCTAssertNil(Params.bankPreviewSlotField(15)) + } + func testEffectTypes() { XCTAssertEqual(Params.effectTypeName(32), "Kemper Drive") XCTAssertEqual(Params.effectTypeName(193), "Spring Reverb") XCTAssertNil(Params.effectTypeName(5)) } + func testEffectCategories() { + XCTAssertEqual(Params.effectCategoryName(16), "Wah") + XCTAssertEqual(Params.effectCategoryName(17), "Shaper") + // A type with no name still resolves to its block. + XCTAssertEqual(Params.effectCategoryName(76), "Modulation") + XCTAssertNil(Params.effectCategoryName(0)) + XCTAssertNil(Params.effectCategoryName(300)) + } + func testRealtimePageAddresses() { XCTAssertEqual(Params.pageName(0x7C), "Realtime/Meters") XCTAssertEqual( @@ -528,6 +568,36 @@ final class StateTests: XCTestCase { XCTAssertEqual(effect.typeName, "Easy Reverb") } + func testExtStringPopulatesBankPreview() { + var state = DeviceState() + // Page 0x96: number 0 = slot 1 rig, 5 = slot 1 amp, 12 = slot 3 cabinet. + let outcome = state.apply(extString(page: 0x96, number: 0, text: "AC30")) + XCTAssertTrue(outcome.slowChanged) + XCTAssertEqual(outcome.events, [.bankPreview(number: 0)]) + state.apply(extString(page: 0x96, number: 5, text: "Vox AC30TB")) + state.apply(extString(page: 0x96, number: 12, text: "2x12")) + XCTAssertEqual(state.bank.slots[0].rigName, "AC30") + XCTAssertEqual(state.bank.slots[0].ampName, "Vox AC30TB") + XCTAssertEqual(state.bank.slots[2].cabinetName, "2x12") + // An out-of-range bank number is ignored. + let ignored = state.apply(extString(page: 0x96, number: 15, text: "x")) + XCTAssertEqual(ignored, ApplyOutcome()) + } + + func testHeadphoneVolumeFeedsMaster() { + var state = DeviceState() + XCTAssertNil(state.output.masterVolume) + // Monitor alone answers for master until headphone is seen. + state.apply(Nrpn.setSingle(product: 0, device: 0, page: 0x7F, number: 2, value: 5000)) + XCTAssertEqual(state.output.masterVolume, 5000) + let outcome = state.apply( + Nrpn.setSingle(product: 0, device: 0, page: 0x7F, number: 1, value: 9000)) + XCTAssertTrue(outcome.slowChanged) + XCTAssertEqual(state.output.headphoneVolume, 9000) + // Headphone wins once present. + XCTAssertEqual(state.output.masterVolume, 9000) + } + func testTunerInTuneWindow() { var tuner = Tuner() XCTAssertNil(tuner.inTune) @@ -875,3 +945,67 @@ final class FmtTests: XCTestCase { XCTAssertEqual(Fmt.asciiOrHex([0xF0, 0x00]), "[f0 00]") } } + +// MARK: - CBOR channel + +final class CborTests: XCTestCase { + func testEncodesWithMinimalLengthHeads() { + XCTAssertEqual( + Fmt.hex(Cbor.encode(Cbor.paramWrite(addr: 15953, value: 0))), "c18301193e5100") + XCTAssertEqual( + Fmt.hex(Cbor.encode(Cbor.paramWrite(addr: 102405, value: 19))), "c183011a0001900513") + } + + func testEncodesTheStateDumpRequest() { + XCTAssertEqual(Fmt.hex(Cbor.encode(Cbor.stateDumpRequest())), "c183011a0001908001") + } + + func testRoundTripsThroughTheDecoder() { + for item in [ + Cbor.paramWrite(addr: 102528, value: 1), + Cbor.paramWrite(addr: 0, value: 0), + Cbor.paramWrite(addr: 16383, value: -1), + ] { + var decoder = CBORDecoder() + XCTAssertEqual(decoder.push(Cbor.encode(item)), [item]) + XCTAssertEqual(decoder.pending, 0) + } + } + + func testDecoderSkipsInterItemFiller() { + var bytes: [UInt8] = [Generated.cborFillerByte, Generated.cborFillerByte] + let item = Cbor.paramWrite(addr: 1412, value: 8629) + bytes.append(contentsOf: Cbor.encode(item)) + var decoder = CBORDecoder() + XCTAssertEqual(decoder.push(bytes), [item]) + XCTAssertEqual(decoder.fillerBytes, 2) + } + + func testExtractsPositionFromAMultiRun() { + // tag(1)([2, 100700, 0, 1, 2]): 100700, then bank 100701 and slot 100702. + let run = CBORValue.tag( + 1, .array([.uint(2), .uint(100_700), .uint(0), .uint(1), .uint(2)])) + let snap = Cbor.extractSnapshot([run]) + XCTAssertEqual(snap.currentBank, 1) + XCTAssertEqual(snap.currentRigSlot, 2) + XCTAssertTrue(snap.isComplete) + } + + func testExtractsPositionFromSingleItems() { + let snap = Cbor.extractSnapshot([ + Cbor.paramWrite(addr: 100_701, value: 3), + Cbor.paramWrite(addr: 100_702, value: 4), + ]) + XCTAssertEqual(snap.currentBank, 3) + XCTAssertEqual(snap.currentRigSlot, 4) + } + + func testCollectsStringsAndRedactsSecrets() { + let name = CBORValue.tag(1, .array([.uint(4), .uint(1), .text("Maz 18 Pushed")])) + let secret = CBORValue.tag( + 1, .array([.uint(4), .uint(UInt64(Generated.sensitiveAddresses[0])), .text("hunter2")])) + let snap = Cbor.extractSnapshot([name, secret]) + XCTAssertEqual(snap.string(1), "Maz 18 Pushed") + XCTAssertEqual(snap.string(Generated.sensitiveAddresses[0]), Generated.redactedPlaceholder) + } +} From 6d37cb3cebc016cdd8d81ceec241cc86ee5377ec Mon Sep 17 00:00:00 2001 From: Aaron Gotwalt Date: Sat, 22 Aug 2026 16:11:31 -0700 Subject: [PATCH 3/5] Add MetersApp, a native SwiftUI dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `meters` terminal example rebuilt as a macOS app: rig header, amp and cabinet, the eight effect blocks with their on/off state and type, the tuner strobe and the level bars. Unlike the terminal example it is not read-only — clicking a signal-chain block toggles that effect — but everything else it sends is a value request. Two things shape the design. The high-rate lane is deliberately not published: status events fold into a private frame and a 33 ms tick publishes it once, so a 300 Hz stream does not drive 300 view updates a second. And every restart bumps an epoch counter that captured tasks check before writing, so a connection still unwinding can never write into the state of its successor. Rig position comes from the device wherever possible — seeded from the CBOR snapshot at connect, then kept live by the reports the device sends on every rig change — falling back to matching the loaded rig name against the bank preview. The app's own optimistic slot hint clears when a change arrives that it did not make. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S --- swift/Package.swift | 2 + swift/Sources/MetersApp/Components.swift | 469 ++++++++++++++ swift/Sources/MetersApp/DashboardView.swift | 392 ++++++++++++ swift/Sources/MetersApp/DeviceStore.swift | 642 ++++++++++++++++++++ swift/Sources/MetersApp/Log.swift | 127 ++++ swift/Sources/MetersApp/MetersApp.swift | 37 ++ swift/Sources/MetersApp/SettingsView.swift | 64 ++ 7 files changed, 1733 insertions(+) create mode 100644 swift/Sources/MetersApp/Components.swift create mode 100644 swift/Sources/MetersApp/DashboardView.swift create mode 100644 swift/Sources/MetersApp/DeviceStore.swift create mode 100644 swift/Sources/MetersApp/Log.swift create mode 100644 swift/Sources/MetersApp/MetersApp.swift create mode 100644 swift/Sources/MetersApp/SettingsView.swift diff --git a/swift/Package.swift b/swift/Package.swift index f036abf..4a75c33 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -7,10 +7,12 @@ let package = Package( products: [ .library(name: "LibKP", targets: ["LibKP"]), .executable(name: "meters", targets: ["meters"]), + .executable(name: "MetersApp", targets: ["MetersApp"]), ], targets: [ .target(name: "LibKP"), .executableTarget(name: "meters", dependencies: ["LibKP"]), + .executableTarget(name: "MetersApp", dependencies: ["LibKP"]), .testTarget(name: "LibKPTests", dependencies: ["LibKP"]), ] ) diff --git a/swift/Sources/MetersApp/Components.swift b/swift/Sources/MetersApp/Components.swift new file mode 100644 index 0000000..9f7d36e --- /dev/null +++ b/swift/Sources/MetersApp/Components.swift @@ -0,0 +1,469 @@ +import LibKP +import SwiftUI + +// The reusable pieces the dashboard is assembled from: indicators, bars, the +// block cards, the effect grid, the tuner strobe and the meter list. + +// MARK: - Indicators + +/// The block on/off indicator — the GUI counterpart of the terminal example's +/// `mark()`: ● on, ○ off, · unknown. +struct StatusDot: View { + /// The block's state, or `nil` if the device has not reported it yet. + let on: Bool? + + var body: some View { + ZStack { + switch on { + case .some(true): + Circle().fill(Color.green) + case .some(false): + Circle().strokeBorder(Color.secondary, lineWidth: 1.5) + case .none: + Circle().fill(Color.secondary.opacity(0.4)).frame(width: 5, height: 5) + } + } + .frame(width: 9, height: 9) + } +} + +// MARK: - Bars + +/// A thin accent-colored bar for secondary values such as gain and effect mix. +struct MiniBar: View { + /// The fill amount, clamped to 0…1. + let fraction: Double + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(.quaternary) + Capsule() + .fill(Color.accentColor) + .frame(width: geometry.size.width * clamp(fraction)) + } + } + .frame(height: 4) + } +} + +/// A level meter with a peak-hold tick. +/// +/// The gradient always spans the whole track — green through yellow into red — +/// and the level simply reveals more of it, so a given color always means the +/// same level regardless of how far the bar has filled. +struct LevelBarView: View { + /// The current level, clamped to 0…1. + let fraction: Double + /// The decaying peak-hold position, clamped to 0…1. Hidden when ~zero. + let peak: Double + + private static let tickWidth: Double = 2.5 + + private static let gradient = LinearGradient( + stops: [ + Gradient.Stop(color: .green, location: 0), + Gradient.Stop(color: .green, location: 0.55), + Gradient.Stop(color: .yellow, location: 0.75), + Gradient.Stop(color: .red, location: 0.95), + ], + startPoint: .leading, + endPoint: .trailing + ) + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + ZStack(alignment: .leading) { + Capsule().fill(.quaternary) + Capsule() + .fill(LevelBarView.gradient) + .mask(alignment: .leading) { + Rectangle().frame(width: width * clamp(fraction)) + } + if peak > 0.01 { + Capsule() + .fill(Color.primary) + .frame(width: LevelBarView.tickWidth) + .offset(x: (width - LevelBarView.tickWidth) * clamp(peak)) + } + } + } + .frame(height: 10) + .animation(.linear(duration: 0.05), value: fraction) + } +} + +// MARK: - Blocks + +/// One boxed signal-chain block: an icon, a title with its on/off dot, the +/// block's name, and an optional trailing accessory. +struct BlockCard: View { + /// SF Symbol shown at the leading edge. + let icon: String + /// The block's label, e.g. "Amplifier". + let title: String + /// The block's name as reported by the device. + let name: String? + /// The block's on/off state, or `nil` if unknown. + let on: Bool? + + private let accessory: Accessory + + init( + icon: String, title: String, name: String?, on: Bool?, + @ViewBuilder accessory: () -> Accessory + ) { + self.icon = icon + self.title = title + self.name = name + self.on = on + self.accessory = accessory() + } + + var body: some View { + GroupBox { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + StatusDot(on: on) + } + Text(name?.trimmed.nonEmpty ?? "—") + .font(.callout) + .fontWeight(.medium) + .lineLimit(1) + } + Spacer(minLength: 0) + accessory + } + .padding(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +extension BlockCard where Accessory == EmptyView { + /// A block card with no trailing accessory. + init(icon: String, title: String, name: String?, on: Bool?) { + self.init(icon: icon, title: title, name: name, on: on) { EmptyView() } + } +} + +/// The global output readouts: the rig's own volume and the physical Master +/// Volume knob. Both are shown read-only — master especially, since the knob is +/// a potentiometer the device treats as ground truth (there is no soft-takeover +/// to write against). +struct OutputCard: View { + /// Rig Volume (NRPN `0x04/1`), or `nil` if not seen yet. + let rigVolume: UInt16? + /// The Master Volume knob's value (Headphone/Monitor), or `nil` if unseen. + let masterVolume: UInt16? + + var body: some View { + GroupBox { + HStack(spacing: 10) { + Image(systemName: "dial.medium") + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: 28) + VStack(alignment: .leading, spacing: 5) { + Text("Output") + .font(.caption) + .foregroundStyle(.secondary) + volumeRow("Rig", rigVolume) + volumeRow("Master", masterVolume) + } + } + .padding(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder private func volumeRow(_ label: String, _ value: UInt16?) -> some View { + HStack(spacing: 6) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: 46, alignment: .leading) + if let value { + MiniBar(fraction: Double(value) / MeterFrame.fullScale) + Text(Format.percent(value)) + .font(.caption2) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 34, alignment: .trailing) + } else { + Text("—") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + +/// The eight effect slots A B C D X MOD DLY REV, laid out four across. +struct EffectGridView: View { + /// The slots in signal-chain order. + let effects: [Effect] + /// Called with a slot's short name when its card is clicked. + let toggle: (String) -> Void + + var body: some View { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 4), + spacing: 10 + ) { + ForEach(effects, id: \.slot) { effect in + EffectCardView(effect: effect) { toggle(effect.slot) } + } + } + } +} + +/// One effect slot: its short name, on/off dot, effect type, category, and mix. +/// Clicking the card toggles the slot; an empty (or not-yet-reported) slot is +/// inert and drawn dimmed. +struct EffectCardView: View { + /// The slot to render. + let effect: Effect + /// Ask the device to flip the slot on or off. + let toggle: () -> Void + + @State private var hovering = false + + /// Only a loaded slot with a known state can be toggled. + private var togglable: Bool { !effect.isEmpty && effect.on != nil } + + var body: some View { + Button(action: toggle) { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(effect.slot) + .font(.caption.bold()) + .foregroundStyle(.secondary) + Spacer() + StatusDot(on: effect.on) + } + Text(typeText) + .font(.callout) + .fontWeight(.medium) + .foregroundStyle(effect.isEmpty ? Color.secondary : Color.primary) + .lineLimit(1) + // A blank line rather than no line, so cards stay the same height. + Text(effect.categoryName ?? " ") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + if !effect.isEmpty, let mix = effect.mix { + HStack(spacing: 6) { + MiniBar(fraction: Double(mix) / MeterFrame.fullScale) + Text(Format.percent(mix)) + .font(.caption2) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 34, alignment: .trailing) + } + } else { + // Keep every card the same height whether or not it has a mix. + Color.clear.frame(height: 4) + } + } + .opacity(effect.isEmpty ? 0.45 : 1) + .padding(10) + .frame(maxWidth: .infinity, minHeight: 88, alignment: .topLeading) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(fill)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(border)) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(!togglable) + .onHover { hovering = $0 } + .help(togglable ? "Turn \(effect.slot) \(effect.on == true ? "off" : "on")" : "") + } + + private var typeText: String { + if effect.isEmpty { return "Empty" } + return effect.typeName ?? effect.kind.map { "Type \($0)" } ?? "—" + } + + private var fill: Color { + if effect.isEmpty { return Color.primary.opacity(0.02) } + let base = effect.on == true ? Color.accentColor : Color.primary + return base.opacity(effect.on == true ? (hovering ? 0.14 : 0.08) : (hovering ? 0.08 : 0.04)) + } + + private var border: Color { + if effect.isEmpty { return Color.primary.opacity(0.05) } + return effect.on == true + ? Color.accentColor.opacity(0.5) + : Color.primary.opacity(hovering ? 0.2 : 0.08) + } +} + +// MARK: - Tuner + +/// The tuner strobe track. +/// +/// Meter field v3 is a wrapping 0–16383 phase whose *rotation rate* tracks +/// pitch deviance: the marker slides along the track, drifting left when the +/// note is sharp and right when it is flat, and holds still when it is in tune. +/// The dots are the track; the diamond is the current phase. The track lays +/// dots at a fixed pitch, so it fills whatever width it is given. +struct TunerStrobeView: View { + /// The latest rendered frame. + let meters: MeterFrame + + private static let inset: Double = 4 + private static let dotSpacing: Double = 9 + private static let dotRadius: Double = 1.5 + private static let markerRadius: Double = 6 + + var body: some View { + Canvas { context, size in + let inset = TunerStrobeView.inset + let span = size.width - inset * 2 + let midline = size.height / 2 + let dotCount = max(Int(span / TunerStrobeView.dotSpacing) + 1, 2) + + for i in 0.. String { + let short = + MeterListView.shortLabels[field.id] + ?? field.id.replacingOccurrences(of: "_", with: " ").capitalized + return showAll ? "v\(field.index) \(short)" : short + } + + private func rangeText(_ field: MeterField) -> String { + let bounds = meters.range(field.index) + return "\(bounds.low)–\(bounds.high)" + } +} + +// MARK: - Formatting + +/// The value formatting the dashboard shares with the terminal example. +enum Format { + /// A 14-bit value as a whole-number percentage of full scale. + static func percent(_ value: UInt16) -> String { + String(format: "%.0f%%", Double(value) / MeterFrame.fullScale * 100) + } +} + +extension String { + /// The string with leading and trailing whitespace removed. + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } + /// `nil` if the string is empty, otherwise the string. + var nonEmpty: String? { isEmpty ? nil : self } +} + +/// Clamp to the 0…1 unit range the bars and the strobe track work in. +private func clamp(_ value: Double) -> Double { min(max(value, 0), 1) } diff --git a/swift/Sources/MetersApp/DashboardView.swift b/swift/Sources/MetersApp/DashboardView.swift new file mode 100644 index 0000000..e53b2e3 --- /dev/null +++ b/swift/Sources/MetersApp/DashboardView.swift @@ -0,0 +1,392 @@ +import AppKit +import LibKP +import SwiftUI + +// The main window: the whole dashboard when a session is live, and the +// connect / retry placeholder when it is not. + +// MARK: - Window + +/// The app's one window. It swaps between the dashboard and the connection +/// placeholder, and carries the status badge and the Reconnect button. +struct DashboardView: View { + @EnvironmentObject private var store: DeviceStore + @AppStorage(SettingsKeys.showAllMeters) private var showAllMeters = false + + var body: some View { + Group { + if case .connected = store.phase { + dashboard + } else { + ConnectionPlaceholderView(phase: store.phase) { store.restart() } + } + } + .frame(minWidth: 700, minHeight: 600) + .navigationTitle(windowTitle) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + store.restart() + } label: { + Label("Reconnect", systemImage: "arrow.clockwise") + } + .keyboardShortcut("r") + .help("Drop the session and connect again") + } + } + } + + /// "KP Meters", plus the device once one is in play — its discovery name + /// when known, and its address. + private var windowTitle: String { + switch store.phase { + case let .connected(host, name): + if let name = name?.trimmed.nonEmpty { return "KP Meters - \(name) · \(host)" } + return "KP Meters - \(host)" + case let .connecting(host): + return "KP Meters - \(host)" + default: + return "KP Meters" + } + } + + private var dashboard: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + RigNavigationView() + RigHeaderView(state: store.state, beatActive: store.meters.beatActive) + } + + HStack(spacing: 12) { + BlockCard( + icon: "guitars", title: "Amplifier", + name: store.state.amp.name, on: store.state.amp.on + ) { + gainAccessory + } + BlockCard( + icon: "hifispeaker", title: "Cabinet", + name: store.state.cabinet.name, on: store.state.cabinet.on) + TunerBlockCard(meters: store.meters) + OutputCard( + rigVolume: store.state.rig.volume, + masterVolume: store.state.output.masterVolume) + } + + section("Signal Chain", icon: "slider.horizontal.3") { + EffectGridView(effects: store.state.effects) { store.toggleEffect($0) } + } + section("Levels", icon: "waveform") { + MeterListView(meters: store.meters, showAll: showAllMeters) + } + } + .padding(16) + } + } + + @ViewBuilder private var gainAccessory: some View { + if let gain = store.state.amp.gain { + VStack(alignment: .trailing, spacing: 4) { + Text("Gain \(Format.percent(gain))") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + MiniBar(fraction: Double(gain) / MeterFrame.fullScale) + .frame(width: 70) + } + } + } + + /// A titled box around one part of the dashboard. + private func section( + _ title: String, icon: String, @ViewBuilder content: () -> Content + ) -> some View { + GroupBox { + content() + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + } label: { + Label(title, systemImage: icon) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + } +} + +// MARK: - Rig navigation + +/// Rig switching: previous/next rig, previous/next bank, and the five slot +/// buttons — each labelled with the rig name the current bank holds in that +/// slot. The slot names come from the bank preview the device pushes on every +/// bank change (also read at connect), so they always describe the bank the +/// device is actually on. +/// +/// The position readout is the device's own, never this app's arithmetic: the +/// CBOR snapshot seeds it at connect and the Bank Select / Program Change pair +/// keeps it live, so it follows front-panel changes as readily as taps here. +/// It reads `—` only before the first report has arrived. +/// +/// Every move is computed in the flat rig index and sent as an absolute bank +/// preselect plus a slot load, so any rig is one hop away and bank boundaries +/// are not special. Nothing assumes how many rigs the device holds. +/// +/// The highlighted slot prefers this app's last tap, then the device's reported +/// slot, then matching the loaded rig name against the five preview names +/// (`DeviceStore.deviceSlot`). +struct RigNavigationView: View { + @EnvironmentObject private var store: DeviceStore + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + HStack(spacing: 4) { + Button { + store.stepRig(by: -1) + } label: { + Image(systemName: "chevron.left") + } + .help("Previous rig") + Button { + store.stepRig(by: 1) + } label: { + Image(systemName: "chevron.right") + } + .help("Next rig") + } + + Divider().frame(height: 16) + + HStack(spacing: 4) { + Button { + store.stepBank(forward: false) + } label: { + Image(systemName: "chevron.left.2") + } + .help("Previous bank") + Button { + store.stepBank(forward: true) + } label: { + Image(systemName: "chevron.right.2") + } + .help("Next bank") + } + + Divider().frame(height: 16) + + Text(position) + .font(.callout) + .monospacedDigit() + .foregroundStyle(.secondary) + .help("Where the device reports it is") + + Spacer() + } + .buttonStyle(.bordered) + .controlSize(.small) + + HStack(spacing: 6) { + ForEach(1...5, id: \.self) { slot in + slotButton(slot) + } + } + } + } + + /// The device's reported position, or an em-dash before the first report. + private var position: String { + guard let bank = store.bank, let slot = store.slot else { return "Bank — · Rig —" } + return "Bank \(bank) · Rig \(slot)" + } + + /// The bank-preview slot for a 1-based slot number, if the device has sent it. + private func previewSlot(_ slot: Int) -> BankSlot? { + let slots = store.state.bank.slots + return slots.indices.contains(slot - 1) ? slots[slot - 1] : nil + } + + private func slotButton(_ slot: Int) -> some View { + let preview = previewSlot(slot) + let name = preview?.rigName?.trimmed.nonEmpty + let selected = store.highlightedSlot == slot + return Button { + store.selectSlot(slot) + } label: { + VStack(spacing: 2) { + Text("\(slot)") + .font(.caption.bold()) + .monospacedDigit() + Text(name ?? "—") + .font(.caption2) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .padding(.horizontal, 4) + .foregroundStyle(slotTextColor(selected: selected, hasName: name != nil)) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(selected ? Color.accentColor : Color.primary.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder( + selected ? Color.accentColor : Color.primary.opacity(0.12), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + .help(slotHelp(slot, preview)) + } + + /// The slot label colour: white on the accent fill when selected, otherwise + /// primary for a named slot and secondary for an empty one. + private func slotTextColor(selected: Bool, hasName: Bool) -> Color { + if selected { return .white } + return hasName ? .primary : .secondary + } + + /// A tooltip naming the rig and, when known, its amp and cabinet. + private func slotHelp(_ slot: Int, _ preview: BankSlot?) -> String { + guard let preview, let rig = preview.rigName?.trimmed.nonEmpty else { + return "Load slot \(slot)" + } + var parts = [rig] + if let amp = preview.ampName?.trimmed.nonEmpty { parts.append(amp) } + if let cab = preview.cabinetName?.trimmed.nonEmpty { parts.append(cab) } + return parts.joined(separator: " · ") + } +} + +// MARK: - Header + +/// The loaded rig: its name and author, with the morph position and the rig +/// tempo — whose metronome icon flashes on the beat pulse — at the trailing +/// edge. +struct RigHeaderView: View { + /// The latest snapshot. + let state: DeviceState + /// True during the brief window after an "on" beat pulse. + let beatActive: Bool + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(state.rig.name?.trimmed.nonEmpty ?? "—") + .font(.system(.title, design: .rounded).weight(.bold)) + .lineLimit(1) + if let author = state.rig.author?.trimmed.nonEmpty { + Text("by \(author)") + .font(.title3) + .foregroundStyle(.secondary) + } + Spacer() + if let morph = state.morph { + Label("Morph \(Format.percent(morph))", systemImage: "arrow.left.and.right") + .font(.callout) + .monospacedDigit() + .foregroundStyle(.secondary) + } + if let tempo = state.rig.tempoBpm { + Label { + Text("\(tempo) BPM") + } icon: { + Image(systemName: beatActive ? "metronome.fill" : "metronome") + .foregroundStyle(beatActive ? Color.yellow : Color.secondary) + } + .font(.callout) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } +} + +// MARK: - Connection + +/// What the window shows while there is no live session: the discovery and +/// connect progress, or the failure with a way out of it. +struct ConnectionPlaceholderView: View { + /// The current connect phase. + let phase: Phase + /// Start another connect cycle. + let retry: () -> Void + + var body: some View { + VStack(spacing: 14) { + switch phase { + case .idle, .discovering: + ProgressView() + .controlSize(.large) + Text("Looking for a Profiler…") + .font(.title3) + .fontWeight(.medium) + Text("Broadcasting discovery on the local network.") + .font(.callout) + .foregroundStyle(.secondary) + case let .connecting(host): + ProgressView() + .controlSize(.large) + Text("Connecting to \(host)…") + .font(.title3) + .fontWeight(.medium) + case let .failed(message): + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text(message) + .multilineTextAlignment(.center) + .frame(maxWidth: 380) + HStack(spacing: 10) { + Button("Try Again", action: retry) + Button("Settings…") { openSettingsWindow() } + } + case .connected: + EmptyView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// Open the Settings scene. `SettingsLink` would be the tidy way to do this, +/// but it needs macOS 14, so ask AppKit to fire the standard action instead. +@MainActor +func openSettingsWindow() { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) +} + +// MARK: - Tuner block + +/// The tuner as a compact block alongside the amp and cabinet: the verdict on +/// the title row, the strobe track underneath. +struct TunerBlockCard: View { + /// The latest rendered frame. + let meters: MeterFrame + + var body: some View { + GroupBox { + HStack(spacing: 10) { + Image(systemName: "tuningfork") + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text("Tuner") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(meters.verdict.text) + .font(.caption.weight(.semibold)) + .foregroundStyle(meters.verdict.color) + } + TunerStrobeView(meters: meters) + } + } + .padding(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/swift/Sources/MetersApp/DeviceStore.swift b/swift/Sources/MetersApp/DeviceStore.swift new file mode 100644 index 0000000..008d7ac --- /dev/null +++ b/swift/Sources/MetersApp/DeviceStore.swift @@ -0,0 +1,642 @@ +import Foundation +import LibKP + +// MARK: - Settings + +/// How the app finds a Profiler: broadcast discovery, or a typed-in address. +enum ConnectionMode: String { + /// Broadcast the UDP discovery poll and take the first device that answers. + case automatic + /// Connect straight to the address stored in ``SettingsKeys/manualHost``. + case manual +} + +/// The `UserDefaults` keys the app and its Settings scene share. +enum SettingsKeys { + /// A ``ConnectionMode`` raw value. + static let mode = "connectionMode" + /// The IPv4 address used in ``ConnectionMode/manual``. + static let manualHost = "manualHost" + /// Whether the level list shows all eleven raw meter fields. + static let showAllMeters = "showAllMeters" +} + +// MARK: - Connection phase + +/// Where the app is in its connect / retry cycle. Everything except +/// ``connected`` renders as a placeholder instead of the dashboard. +enum Phase: Equatable { + /// Nothing started yet. + case idle + /// Broadcasting the discovery poll. + case discovering + /// A session is being opened to `host`. + case connecting(host: String) + /// A session is live; `name` is the device's advertised discovery name. + case connected(host: String, name: String?) + /// The last attempt failed; `message` is shown to the user. + case failed(message: String) +} + +// MARK: - Tuner verdict + +/// The tuner readout distilled from the strobe phase drift rate. +enum StrobeVerdict: Equatable { + /// The tuner block is not producing data. + case idle + /// Active, but not enough phase samples yet to call it. + case measuring + /// The strobe is (near enough) stationary. + case inTune + /// The phase is falling — the note is above pitch. + case sharp + /// The phase is rising — the note is below pitch. + case flat +} + +// MARK: - Meter frame + +/// One rendered frame of the FAST lane: the eleven meter values plus everything +/// derived from their history — peak-hold markers, the observed range, the +/// message rate, the strobe position and verdict, and the beat pulse. +/// +/// The store accumulates into a private copy and publishes it once per render +/// tick, so a 300 Hz stream never drives 300 view updates a second. +struct MeterFrame: Equatable { + /// The eleven raw 14-bit values, in NRPN order. + var values = [UInt16](repeating: 0, count: Generated.meterCount) + /// Peak-hold markers, in raw units, decayed on every tick. + var peaks = [Double](repeating: 0, count: Generated.meterCount) + /// The lowest value seen per field since the last connect. + var mins = [UInt16](repeating: .max, count: Generated.meterCount) + /// The highest value seen per field since the last connect. + var maxs = [UInt16](repeating: 0, count: Generated.meterCount) + /// How many status frames have arrived since the last connect. + var frames: UInt64 = 0 + /// Status frames per second over the trailing window. + var rate: Double = 0 + /// True while the tuner block is producing data. + var strobeActive = false + /// The strobe phase as a 0…1 position along the track. + var strobePosition: Double = 0 + /// The in-tune / sharp / flat call derived from the phase drift. + var verdict: StrobeVerdict = .idle + /// True during the brief window after an "on" beat pulse. + var beatActive = false + + /// The 14-bit full-scale value, as a `Double` for the bar math. + static let fullScale = Double(Generated.fullScale) + + /// A field's current value as a 0…1 fraction of full scale. + func fraction(_ index: Int) -> Double { Double(values[index]) / MeterFrame.fullScale } + + /// A field's peak-hold marker as a 0…1 fraction of full scale. + func peakFraction(_ index: Int) -> Double { peaks[index] / MeterFrame.fullScale } + + /// The low/high range observed for a field, or `(0, 0)` if nothing has been + /// seen yet (the seeded minimum is still above the seeded maximum). + func range(_ index: Int) -> (low: UInt16, high: UInt16) { + mins[index] <= maxs[index] ? (mins[index], maxs[index]) : (0, 0) + } +} + +// MARK: - The store + +/// The app's single source of truth: it owns the ``DeviceModel``, drives the +/// connect / retry cycle, and turns the two library streams into published +/// state the views bind to. +/// +/// The high-rate lane is deliberately *not* published. Status events fold into +/// a private ``MeterFrame``; a 33 ms render tick decays the peak-hold markers, +/// recomputes the derived values and publishes the frame once. The coalesced +/// snapshot stream, which already fires at most once per ingested chunk, is +/// published as it arrives. +/// +/// Every restart bumps an epoch counter. Tasks captured the epoch they were +/// started under and bail the moment it no longer matches, so a connection +/// that is still unwinding can never write into the state of its successor. +@MainActor +final class DeviceStore: ObservableObject { + /// Where the app is in its connect / retry cycle. + @Published private(set) var phase: Phase = .idle { + didSet { + guard phase != oldValue else { return } + Log.conn("phase \(oldValue.label) -> \(phase.label)") + } + } + /// The latest SLOW snapshot: rig, amp, cabinet, effects, tuner, output. + @Published private(set) var state = DeviceState() + /// The latest rendered FAST frame. + @Published private(set) var meters = MeterFrame() + /// The slot (1–5) this app selected last, for immediate feedback on a tap. + /// The device does not report its rig index over the network, so this is + /// app-local; it clears when the rig changes from anywhere else (front panel, + /// another controller) so ``deviceSlot`` can take over. + @Published private(set) var selectedSlot: Int? { + didSet { + guard selectedSlot != oldValue else { return } + Log.ui("selectedSlot \(Log.opt(oldValue)) -> \(Log.opt(selectedSlot))") + } + } + /// The bank the device is on, 1-based, or `nil` before the first position + /// report. Read straight from the state tree: seeded at connect from the + /// CBOR snapshot, then kept live by the Bank Select / Program Change pair + /// the device sends on every rig change — including changes made at the + /// front panel. Never stepped or inferred by this app. + var bank: Int? { state.currentBank.map { Int($0) + 1 } } + + /// The slot the device has loaded, 1-based, from the same live report. + var slot: Int? { state.currentRigSlot.map { Int($0) + 1 } } + + /// The device's flat rig index — its own numbering, and the address every + /// navigation is computed in. + var rigIndex: UInt16? { state.currentRigIndex } + + /// The bank slot the device has loaded, *inferred* by matching the loaded + /// rig name against the bank preview's five rig names. This is the live + /// fallback until the device's own ``slot`` is known: the device pushes a + /// fresh preview on every bank change, so the match follows navigation that + /// the boot snapshot cannot. `nil` when the current rig name is not one the + /// bank preview carries (e.g. two slots share a name). + var deviceSlot: Int? { + guard let name = state.rig.name?.trimmed.nonEmpty else { return nil } + for (index, slot) in state.bank.slots.enumerated() + where slot.rigName?.trimmed.nonEmpty == name { + return index + 1 + } + return nil + } + + /// Which slot the rig navigator highlights: this app's optimistic last tap + /// while it is fresh, then the device's own reported slot while it is still + /// valid, then the name-matched inference. + var highlightedSlot: Int? { selectedSlot ?? slot ?? deviceSlot } + + private var model: DeviceModel? + private var tasks: [Task] = [] + private var epoch = 0 + + // The fast lane, accumulated off the published properties. + private var frame = MeterFrame() + private var recent: [Date] = [] + private var lastDecay = Date() + private var lastPulse: (at: Date, on: Bool)? + private var phaseHistory: [(at: Date, phase: UInt16)] = [] + /// When this app last navigated rigs, so a rig change arriving well after + /// it can be recognized as externally driven. + private var lastNavigation = Date.distantPast + + /// How long to wait before another attempt after a failure. + private static let retryDelay: TimeInterval = 4 + /// Breathing room between one connection closing and the next opening — the + /// device resets, or silently refuses to greet, a session opened immediately + /// after the previous socket closed. The value is the shared protocol + /// constant, so all implementations space connections the same way. + private static let connectionSpacing = Session.connectionCooldown + /// The render tick — about 30 frames a second. + private static let tickInterval: TimeInterval = 0.033 + /// The trailing window the message rate is averaged over. + private static let rateWindow: TimeInterval = 2 + /// How long discovery listens for replies. + private static let discoveryWindow: TimeInterval = 3 + + /// The handful of values the header shows that the initial rig sync does + /// not cover. These are requests, so nothing on the device changes. + private static let extraParams: [(page: UInt8, number: UInt8)] = [ + (Generated.ampPage, Generated.ampOnNumber), + (Generated.ampPage, Generated.gainNumber), + (Generated.pageRigSettings, Generated.tempoNumber), + (Generated.pageRigSettings, Generated.rigVolumeNumber), + (Generated.pageMorph, Generated.morphNumber), + (Generated.systemPage, Generated.mainVolumeNumber), + // Headphone + Monitor together are the physical Master Volume knob. + (Generated.systemPage, Generated.headphoneVolumeNumber), + (Generated.systemPage, Generated.monitorVolumeNumber), + ] + + // MARK: - Lifecycle + + /// Start the connect cycle, once. Safe to call from every `onAppear`. + func start() { + guard phase == .idle else { return } + Log.conn("start") + restart() + } + + /// Tear down whatever is running and begin a fresh connect cycle. Also the + /// hook behind the Reconnect button and every Settings change. + func restart() { + epoch += 1 + let epoch = self.epoch + Log.conn("restart (epoch \(epoch))") + + for task in tasks { task.cancel() } + tasks.removeAll() + // Hand the outgoing model to the connect loop so it can close it *before* + // — and spaced from — the next connection, rather than racing a + // fire-and-forget close against the new CBOR fetch. + let closing = model + model = nil + + frame = MeterFrame() + meters = frame + state = DeviceState() + selectedSlot = nil + recent.removeAll() + lastDecay = Date() + lastPulse = nil + phaseHistory.removeAll() + + tasks.append(Task { await self.runConnectLoop(epoch: epoch, closing: closing) }) + } + + /// Resolve an address, connect, and attach the streams — retrying on a + /// timer until it works or the epoch moves on. + /// + /// `closing` is the previous session, if any. The device refuses a new + /// session that follows a close too closely, so it is closed and spaced + /// before the first connection attempt — which starts with the CBOR + /// snapshot. + private func runConnectLoop(epoch: Int, closing: DeviceModel?) async { + if let closing { + await closing.close() + try? await Task.sleep( + nanoseconds: UInt64(DeviceStore.connectionSpacing * 1_000_000_000)) + guard epoch == self.epoch else { return } + } + while !Task.isCancelled && epoch == self.epoch { + let defaults = UserDefaults.standard + let mode = + ConnectionMode(rawValue: defaults.string(forKey: SettingsKeys.mode) ?? "") + ?? .automatic + var host = (defaults.string(forKey: SettingsKeys.manualHost) ?? "").trimmed + var name: String? + + if mode == .manual { + // Retrying would poll an address that does not exist, so stop + // and wait for the user to fill one in. + guard !host.isEmpty else { + phase = .failed(message: "No device address set. Choose one in Settings.") + return + } + } else { + phase = .discovering + do { + let reply = try await Discovery.findFirst( + listenFor: DeviceStore.discoveryWindow) + guard epoch == self.epoch else { return } + guard let reply else { + phase = .failed(message: "No Profiler found on the network.") + await waitBeforeRetry() + continue + } + host = reply.host + name = reply.name + Log.conn("discovered \(reply.host) \(Log.opt(reply.name))") + } catch { + guard epoch == self.epoch else { return } + phase = .failed(message: "Discovery failed: \(error)") + await waitBeforeRetry() + continue + } + } + + phase = .connecting(host: host) + + // Learn the device's real current bank/rig first, over the CBOR + // channel, on its own short-lived connection. The device is fragile + // under concurrent sockets, so this completes and closes *before* the + // streaming session opens; a failure is non-fatal — the name-matched + // fallback still runs — so it never blocks the connect. + Log.conn("fetching CBOR state snapshot from \(host)") + let snapshot = try? await StateSnapshot.fetch(host: host) + guard epoch == self.epoch else { return } + if let snapshot { + Log.conn( + """ + snapshot: bank \(Log.opt(snapshot.currentBank)) \ + slot \(Log.opt(snapshot.currentRigSlot)) (both 0-based) + """) + } else { + Log.conn("snapshot unavailable — falling back to name matching") + } + // Let that socket settle before opening the streaming session — the + // device will not greet one that follows too closely. + if snapshot != nil { + try? await Task.sleep( + nanoseconds: UInt64(DeviceStore.connectionSpacing * 1_000_000_000)) + guard epoch == self.epoch else { return } + } + + do { + let model = try await DeviceModel.connect(host: host) + guard epoch == self.epoch else { + await model.close() + return + } + self.model = model + phase = .connected(host: host, name: name) + + // `DeviceModel.connect` already ran the read-only rig sync. + Log.conn("requesting \(DeviceStore.extraParams.count) extra header params") + for (page, number) in DeviceStore.extraParams { + try? await model.requestParam(page: page, number: number) + } + guard epoch == self.epoch else { return } + + attachStreams(to: model, epoch: epoch) + + // Seed the current position from the snapshot, now that the store + // is subscribed: fold the indices into the model's state (which + // publishes a snapshot the views pick up) and show the real bank + // on the stepper. + if let snapshot { + Log.conn("seeding position from snapshot") + await model.setCurrentPosition( + bank: snapshot.currentBank, slot: snapshot.currentRigSlot) + } + return + } catch { + guard epoch == self.epoch else { return } + Log.conn("connect to \(host) threw: \(error)") + phase = .failed(message: "Could not connect to \(host): \(error)") + await waitBeforeRetry() + } + } + } + + /// Sleep out the retry delay. + private func waitBeforeRetry() async { + try? await Task.sleep(nanoseconds: UInt64(DeviceStore.retryDelay * 1_000_000_000)) + } + + /// Drain the granular event stream, the coalesced snapshot stream, and run + /// the render tick — one task each, all inheriting the main actor. + private func attachStreams(to model: DeviceModel, epoch: Int) { + tasks.append( + Task { + for await event in await model.events() { + guard epoch == self.epoch else { return } + self.handle(event) + } + // The stream only finishes when the device hangs up. + self.handleStreamEnd(epoch: epoch) + }) + + tasks.append( + Task { + for await snapshot in await model.snapshots() { + guard epoch == self.epoch else { return } + self.logSnapshotDelta(from: self.state, to: snapshot) + self.state = snapshot + } + }) + + tasks.append( + Task { + while !Task.isCancelled && epoch == self.epoch { + self.tick() + try? await Task.sleep( + nanoseconds: UInt64(DeviceStore.tickInterval * 1_000_000_000)) + } + }) + } + + /// The device closed the connection: say so, then start over. + private func handleStreamEnd(epoch: Int) { + guard epoch == self.epoch else { return } + Log.conn("event stream finished — the device closed the connection") + phase = .failed(message: "The device closed the connection. Retrying…") + tasks.append( + Task { + await self.waitBeforeRetry() + guard epoch == self.epoch else { return } + self.restart() + }) + } + + // MARK: - Commands + + /// Ask the device to flip an effect slot on or off, then read the state + /// back. The device applies a `$01` write without echoing it, so the + /// read-back is what updates the card — the UI always shows the device's + /// actual state, never an assumed one. + func toggleEffect(_ slot: String) { + guard let model, let effect = state.effect(slot), !effect.isEmpty, let on = effect.on + else { return } + // Fire and forget: a failed write means the session is going down, and + // the connect loop surfaces that on its own. + Log.cmd("toggleEffect \(slot): \(on ? "on -> off" : "off -> on")") + Task { + do { + try await model.setEffectEnabled(slot, !on) + try await model.requestParam(page: effect.page, number: Params.effectParamState) + } catch { + Log.cmd("toggleEffect \(slot) failed: \(error)") + } + } + } + + /// Load slot 1–5 (CC50–54) and remember the choice as the selected-slot hint. + /// + /// When a bank has been preselected, re-arm it (CC47) immediately before the + /// rig-select, in one ordered burst — the device's preselect does not persist + /// across the gap between a bank tap and a later slot tap, so a stale + /// preselect would drop the load into whatever bank is still loaded. Sending + /// the pair back-to-back is the sequence the device is documented to honour; + /// re-arming the already-current bank is harmless. + func selectSlot(_ slot: Int) { + guard let model, (1...5).contains(slot) else { return } + selectedSlot = slot + lastNavigation = Date() + Log.cmd("selectSlot \(slot) (CC\(49 + slot) press/release)") + Task { + do { + try await model.selectRig(UInt8(slot)) + // The device does not volunteer the landing rig's strings after a CC + // rig-select, so read them back to refresh the header/amp/cab/effects. + try await model.refreshRig() + Log.cmd("selectSlot \(slot) sent, rig read-back requested") + } catch { + Log.cmd("selectSlot \(slot) failed: \(error)") + } + } + } + + /// Step `delta` rigs from where the device says it is, and load the result. + /// + /// Everything navigational goes through the flat rig index, because it is + /// the only address that crosses a bank boundary: ±1 is the next or previous + /// rig, ±``Params/bankSlots`` is the next or previous bank at the same slot. + /// The move is a no-op until the device has reported a position, since there + /// is nothing to step *from* — better to do nothing than to guess and jump + /// somewhere arbitrary. + /// + /// The lower bound is 0; there is no upper bound, because how many rigs a + /// device holds varies and nothing here knows it. Aiming past the end leaves + /// the device where it is, and its position report says so. + func stepRig(by delta: Int) { + guard let model, let current = rigIndex else { + Log.cmd("stepRig \(delta) ignored — no position reported yet") + return + } + let target = UInt16(max(Int(current) + delta, 0)) + guard target != current else { return } + let slots = UInt16(Params.bankSlots) + selectedSlot = Int(target % slots) + 1 + lastNavigation = Date() + Log.cmd( + "stepRig \(delta > 0 ? "+" : "")\(delta): index \(current) -> \(target) " + + "(bank \(target / slots + 1), slot \(target % slots + 1))") + Task { + do { + try await model.selectRigIndex(target) + // The device does not volunteer the landing rig's strings, so + // read them back for the header, amp, cab and effects. + try await model.refreshRig() + Log.cmd("stepRig sent, rig read-back requested") + } catch { + Log.cmd("stepRig failed: \(error)") + } + } + } + + /// Step one bank, keeping the slot — ``Params/bankSlots`` rigs at a time. + func stepBank(forward: Bool) { + stepRig(by: forward ? Params.bankSlots : -Params.bankSlots) + } + + // MARK: - Ingest + + /// Fold one granular event into the fast lane. Slow changes arrive through + /// the snapshot stream instead, so everything else is ignored here. + private func handle(_ event: DeviceEvent) { + if let line = Log.describe(event) { Log.evt(line) } + switch event { + case let .status(status): + ingest(status) + case let .beatPulse(on): + lastPulse = (Date(), on) + case .rigChanged: + // A rig change well after our own navigation came from somewhere + // else (front panel, another controller) — the hint is stale. + let sinceNavigation = Date().timeIntervalSince(lastNavigation) + Log.evt(String(format: "rigChanged %.2fs after our last navigation", sinceNavigation)) + if sinceNavigation > 3 { + selectedSlot = nil + } + default: + break + } + } + + /// Log the snapshot fields the rig navigator depends on, whenever they move: + /// the loaded rig, the bank preview's five slot names, and the device's own + /// reported position. An entry here with no preceding `cmd` line is a change + /// this app did not ask for. + private func logSnapshotDelta(from old: DeviceState, to new: DeviceState) { + if old.rig.name != new.rig.name || old.rig.author != new.rig.author { + Log.evt( + "rig \"\(Log.opt(new.rig.name?.trimmed))\" by \(Log.opt(new.rig.author?.trimmed))") + } + let previous = old.bank.slots.map { $0.rigName?.trimmed ?? "" } + let current = new.bank.slots.map { $0.rigName?.trimmed ?? "" } + if previous != current { + Log.evt("bank preview [\(current.joined(separator: " | "))]") + } + if old.currentBank != new.currentBank || old.currentRigSlot != new.currentRigSlot { + Log.evt( + "device position bank \(Log.opt(new.currentBank)) " + + "slot \(Log.opt(new.currentRigSlot))") + } + } + + /// Accumulate one meter frame: values, peak-hold, observed range, the + /// message-rate window and the strobe phase history. + private func ingest(_ status: RealtimeStatus) { + frame.frames += 1 + recent.append(Date()) + for (i, value) in status.raw.enumerated() { + frame.values[i] = value + frame.peaks[i] = max(frame.peaks[i], Double(value)) + frame.mins[i] = min(frame.mins[i], value) + frame.maxs[i] = max(frame.maxs[i], value) + } + frame.strobeActive = status.strobeActive + if status.strobeActive { + phaseHistory.append((Date(), status.strobePhase)) + } else { + phaseHistory.removeAll(keepingCapacity: true) + } + } + + // MARK: - Render tick + + /// Decay the peaks, refresh the derived values, and publish the frame. + private func tick() { + let now = Date() + + // Frame-rate independent peak decay, never below the current value. + let dt = min(now.timeIntervalSince(lastDecay), 0.5) + lastDecay = now + let drop = MeterFrame.fullScale * 0.8 * dt // full-scale peak falls in ~1.25 s + for i in frame.peaks.indices { + frame.peaks[i] = max(frame.peaks[i] - drop, Double(frame.values[i])) + } + + let cutoff = now.addingTimeInterval(-DeviceStore.rateWindow) + recent.removeAll { $0 < cutoff } + frame.rate = Double(recent.count) / DeviceStore.rateWindow + + frame.strobePosition = + Double(frame.values[Generated.strobePhaseIndex]) / MeterFrame.fullScale + + switch (frame.strobeActive, strobeRate()) { + case (false, _): + frame.verdict = .idle + case (true, .none): + frame.verdict = .measuring + case let (true, .some(rate)) where abs(rate) < 600: + frame.verdict = .inTune + case let (true, .some(rate)) where rate < 0: + frame.verdict = .sharp + case (true, .some): + frame.verdict = .flat + } + + if let pulse = lastPulse { + frame.beatActive = pulse.on && now.timeIntervalSince(pulse.at) < 0.15 + } else { + frame.beatActive = false + } + + meters = frame + } + + /// Wrap-aware strobe drift rate in counts/sec over the last ~0.4 s. + /// Positive = phase rising (flat), negative = falling (sharp). + private func strobeRate() -> Double? { + let cutoff = Date().addingTimeInterval(-0.4) + phaseHistory.removeAll { $0.at < cutoff } + guard phaseHistory.count >= 3, + let first = phaseHistory.first, + let last = phaseHistory.last + else { return nil } + var total = 0.0 + for i in 1.. 0.05 ? total / dt : nil + } +} + +/// Euclidean remainder, so a negative dividend still wraps forward. +private func euclideanRemainder(_ lhs: Int, _ rhs: Int) -> Int { + let remainder = lhs % rhs + return remainder < 0 ? remainder + rhs : remainder +} diff --git a/swift/Sources/MetersApp/Log.swift b/swift/Sources/MetersApp/Log.swift new file mode 100644 index 0000000..a9aae93 --- /dev/null +++ b/swift/Sources/MetersApp/Log.swift @@ -0,0 +1,127 @@ +import Foundation +import LibKP + +// Tracing for the connect cycle, the commands this app sends, and the events +// that come back — enough to reconstruct, after the fact, who moved the rig. + +/// Timestamped tracing, written to stderr — visible both under `swift run +/// MetersApp` and in Xcode's console. +/// +/// Only the lines that say *what moved* are on by default. The raw event stream +/// underneath them is enormous — one rig change is ~1500 `paramChanged` and +/// `stringTag` replies, and the meter lane alone runs a few hundred messages a +/// second — so all of it is gated behind `KP_LOG_VERBOSE` in the environment. +/// What survives is the connect cycle, the commands this app sends, and the +/// handful of snapshot fields the rig navigator actually reads. +enum Log { + /// The lane a line belongs to, printed as a fixed-width column. + enum Lane: String { + /// The connect / retry cycle. + case conn + /// A command this app sent to the device. + case cmd + /// Something that arrived from the device. + case evt + /// A published store property changing. + case ui + } + + /// Whether the high-rate lane is logged too. + static let verbose = ProcessInfo.processInfo.environment["KP_LOG_VERBOSE"] != nil + + /// Zero for the elapsed-time column. + private static let start = Date() + /// Serializes writes so interleaved lanes stay whole. + private static let lock = NSLock() + + /// Log a connect-cycle line. + static func conn(_ message: @autoclosure () -> String) { write(.conn, message()) } + /// Log a command this app is sending. + static func cmd(_ message: @autoclosure () -> String) { write(.cmd, message()) } + /// Log something the device sent. + static func evt(_ message: @autoclosure () -> String) { write(.evt, message()) } + /// Log a published store property changing. + static func ui(_ message: @autoclosure () -> String) { write(.ui, message()) } + + /// Render an optional as its value or an em-dash, so a `nil` bank reads the + /// same in the log as it does on the stepper. + static func opt(_ value: Value?) -> String { + value.map { "\($0)" } ?? "—" + } + + /// A one-line rendering of a device event, or `nil` for the ones that say + /// nothing the snapshot deltas do not already say more legibly. + /// + /// A rig load replays the whole parameter tree — every effect slot, every + /// string tag, the amp and cabinet pages — as individual replies. Printing + /// them buries the two lines that matter (the rig changed; the bank preview + /// moved), so the raw stream is ``verbose``-only. + static func describe(_ event: DeviceEvent) -> String? { + switch event { + case .status, .beatPulse, .tunerDeviance, .tunerNote: + return verbose ? "\(event)" : nil + case let .stringTag(number): + return verbose ? "stringTag \(number)" : nil + case let .bankPreview(number): + return verbose ? "bankPreview \(number)" : nil + case let .effectChanged(slot): + return verbose ? "effectChanged slot \(slot)" : nil + case let .paramChanged(page, number, value): + return verbose ? "paramChanged \(hex(page))/\(number) = \(value)" : nil + case let .renderedString(page, number, value, text): + return verbose ? "rendered \(hex(page))/\(number) \(value) = \"\(text)\"" : nil + case .rigChanged: + // The store logs this one itself, with how long it has been since + // our own last navigation — the part that says who caused it. + return nil + case let .tempoBpm(bpm): + return "tempoBpm \(bpm)" + case let .morphChanged(value): + return "morph \(value)" + case let .currentPosition(bank, slot): + return "currentPosition bank \(opt(bank)) slot \(opt(slot))" + case .connected: + return "connected" + case .disconnected: + return "disconnected" + } + } + + /// An NRPN page as `$xx`, the way the docs write it. + private static func hex(_ byte: UInt8) -> String { + "$" + String(byte, radix: 16, uppercase: true).leftPadded(to: 2, with: "0") + } + + private static func write(_ lane: Lane, _ message: String) { + let elapsed = String(format: "%8.3f", Date().timeIntervalSince(start)) + let column = lane.rawValue.padding(toLength: 4, withPad: " ", startingAt: 0) + lock.lock() + defer { lock.unlock() } + fputs("[\(elapsed)] \(column) \(message)\n", stderr) + } +} + +extension String { + /// This string padded on the left to `width`, for fixed-width hex. + fileprivate func leftPadded(to width: Int, with pad: Character) -> String { + count >= width ? self : String(repeating: pad, count: width - count) + self + } +} + +extension Phase { + /// A short, log-friendly name for the phase. + var label: String { + switch self { + case .idle: + return "idle" + case .discovering: + return "discovering" + case let .connecting(host): + return "connecting(\(host))" + case let .connected(host, name): + return "connected(\(host)\(name.map { " · \($0)" } ?? ""))" + case let .failed(message): + return "failed(\(message))" + } + } +} diff --git a/swift/Sources/MetersApp/MetersApp.swift b/swift/Sources/MetersApp/MetersApp.swift new file mode 100644 index 0000000..f2a8f1e --- /dev/null +++ b/swift/Sources/MetersApp/MetersApp.swift @@ -0,0 +1,37 @@ +import AppKit +import SwiftUI + +// `MetersApp` — the `meters` terminal example rebuilt as a native SwiftUI app. +// +// It connects (by discovery, or to an address entered in Settings), performs +// the read-only initial rig sync, and then renders the current patch: rig +// header, amp and cabinet, the eight effect blocks with their on/off state and +// effect type, the tuner strobe, and the level bars. Unlike the terminal +// example it is not purely read-only: clicking a signal-chain block toggles +// that effect on or off. Everything else it sends is a value request. + +@main +struct MetersApp: App { + @StateObject private var store = DeviceStore() + + var body: some Scene { + Window("KP Meters", id: "main") { + DashboardView() + .environmentObject(store) + .onAppear { + // `swift run` starts a bare executable with no app bundle, + // so it launches as an accessory with no menu bar and no + // focus. Promote it and pull it to the front by hand. + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + store.start() + } + } + .defaultSize(width: 760, height: 640) + + Settings { + SettingsView() + .environmentObject(store) + } + } +} diff --git a/swift/Sources/MetersApp/SettingsView.swift b/swift/Sources/MetersApp/SettingsView.swift new file mode 100644 index 0000000..f4772c7 --- /dev/null +++ b/swift/Sources/MetersApp/SettingsView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +// The Settings scene (⌘,): how the app finds the device, and how much of the +// raw meter block the level list shows. + +/// The app's preferences. Every change that affects the session restarts it, +/// so the dashboard always reflects what is configured here. +struct SettingsView: View { + @EnvironmentObject private var store: DeviceStore + @AppStorage(SettingsKeys.mode) private var mode = ConnectionMode.automatic.rawValue + @AppStorage(SettingsKeys.manualHost) private var manualHost = "" + @AppStorage(SettingsKeys.showAllMeters) private var showAllMeters = false + + var body: some View { + Form { + Section("Connection") { + Picker("Find the device", selection: modeBinding) { + Label("Automatic discovery", systemImage: "dot.radiowaves.left.and.right") + .tag(ConnectionMode.automatic.rawValue) + Label("Manual address", systemImage: "network") + .tag(ConnectionMode.manual.rawValue) + } + .pickerStyle(.radioGroup) + + TextField( + "Device address", + text: $manualHost, + prompt: Text("e.g. 192.168.1.50") + ) + .disabled(mode != ConnectionMode.manual.rawValue) + .onSubmit { store.restart() } + } + + Section("Meters") { + Toggle("Show all eleven raw meter fields", isOn: $showAllMeters) + Text( + "Adds the three tuner strobe segment drivers, the strobe phase and the " + + "stack and rig-output power fields to the level list." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Button("Reconnect Now") { store.restart() } + } + } + .formStyle(.grouped) + .frame(width: 440) + } + + /// Writes the choice through to `@AppStorage` and restarts the session in + /// the same step. Reacting in `onChange` would mean the one-parameter form, + /// which is all macOS 13 has and is deprecated from macOS 14 on. + private var modeBinding: Binding { + Binding( + get: { mode }, + set: { newValue in + mode = newValue + store.restart() + } + ) + } +} From 1570250c65dbd24d4b9a6bd0fc35f77ffff82480 Mon Sep 17 00:00:00 2001 From: Aaron Gotwalt Date: Sat, 22 Aug 2026 16:11:38 -0700 Subject: [PATCH 4/5] Bump spec to 0.5.0 and refresh docs, exports and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new CBOR and registry surfaces into each package's public API, updates the overview, credits and per-language READMEs to describe them, and bumps the shared spec version — every implementation asserts it in its conformance suite and CI cross-checks that all three agree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S --- .github/workflows/ci.yml | 1 + CREDITS.md | 6 ++-- README.md | 14 ++++++--- docs/01-overview.md | 11 ++++--- docs/10-versioning-and-compatibility.md | 3 +- docs/README.md | 2 +- python/README.md | 22 ++++++++++---- python/src/libkp/__init__.py | 27 +++++++++++++++++ rust/README.md | 9 +++--- rust/src/lib.rs | 9 +++++- spec/version.toml | 2 +- swift/README.md | 39 ++++++++++++++++++++----- 12 files changed, 113 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 308e1d9..6c65589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,4 @@ jobs: - run: swift build - run: swift test - run: swift build --product meters + - run: swift build --product MetersApp diff --git a/CREDITS.md b/CREDITS.md index 80a2f2d..ac6fdd2 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -36,7 +36,9 @@ device behavior. This includes: - The TCP handshake (the protocol-GUID negotiation and session preamble). - MIDI3 stream framing. - The realtime status / meter block field identities. -- The existence and shape of the native CBOR control channel (documented in - [docs/06](docs/06-cbor-channel.md), not implemented). +- The native CBOR control channel: its wire shape, the state-dump trigger, and + the current bank/rig addresses it returns (documented in + [docs/06](docs/06-cbor-channel.md); `libkp` implements the codec and the + state-dump snapshot, not the wider management grammar). These findings are labeled "observed experimentation" throughout the docs. diff --git a/README.md b/README.md index 0b7834f..b2a7d17 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ languages that stay in lockstep: Each implementation ships the same example — **`meters`**, a live terminal view of the current rig, its effect blocks, the tuner strobe, and the output meters. +The Swift implementation additionally ships **`MetersApp`**, the same dashboard +as a native SwiftUI macOS app. ## Documentation @@ -26,7 +28,7 @@ The protocol is documented in [`docs/`](docs/): 3. [Handshake](docs/03-handshake.md) — the TCP GUID negotiation 4. [MIDI3 framing](docs/04-midi3-framing.md) 5. [SysEx / NRPN dialect](docs/05-sysex-nrpn.md) -6. [The CBOR channel](docs/06-cbor-channel.md) — experimental +6. [The CBOR channel](docs/06-cbor-channel.md) — the current bank/rig snapshot 7. [Realtime status & meters](docs/07-realtime-status.md) 8. [Control model](docs/08-control-model.md) — CC vs NRPN vs the device model 9. [Parameter registry](docs/09-parameter-registry.md) @@ -57,8 +59,10 @@ See [docs/10](docs/10-versioning-and-compatibility.md) for the full mechanism. `libkp` implements the fully-validated MIDI3 surface: discovery, session, framing, NRPN read/write, CC control, the realtime meter/tuner decode, a typed -parameter registry, and an observable device model. The device's native CBOR -control channel is documented (docs/06) but not implemented. +parameter registry, and an observable device model. It also speaks the device's +native CBOR channel for one purpose — the state-dump snapshot that reads the +current bank and rig (docs/06); the channel's wider management grammar is not +implemented. ## Attribution @@ -67,7 +71,9 @@ the control-change map — follows the official [Kemper MIDI Parameter Documentation](https://www.kemper-amps.com/downloads/5/User-Manuals), cross-checked against the excellent [PySwitch](https://github.com/Tunetown/PySwitch) project, whose Kemper client is credited for confirming addresses and the -bidirectional beacon. The transport envelope — discovery, the handshake, MIDI3 +bidirectional beacon. The effect-type **category** blocks are inferred from the +value-range structure of that documentation's Appendix B rather than transcribed +from a printed table. The transport envelope — discovery, the handshake, MIDI3 framing, session encapsulation, and the CBOR channel — was characterized through observed experimentation. See [CREDITS.md](CREDITS.md). diff --git a/docs/01-overview.md b/docs/01-overview.md index 15d5581..e84ba68 100644 --- a/docs/01-overview.md +++ b/docs/01-overview.md @@ -66,13 +66,14 @@ socket, replying inline. ## The four protocol GUIDs -The device offers four dialects at step 4. `libkp` implements the first. +The device offers four dialects at step 4. `libkp` speaks the first fully and +the third for one purpose. | GUID | Role | In `libkp` | |---|---|---| | `{369F50E7-750B-459A-BAEE-85ADD3F3798D}` | MIDI3 stream — meters, requests, replies, control | **implemented** | | `{2490272E-CD92-4DBA-AE32-E8AF37ED3B0A}` | request/response channel; emits an 11-byte token on open | not implemented | -| `{774CDB9E-74ED-4740-AF09-AC96B3A69A11}` | native CBOR control channel | [experimental](06-cbor-channel.md), not implemented | +| `{774CDB9E-74ED-4740-AF09-AC96B3A69A11}` | native CBOR control channel | [the state-dump snapshot only](06-cbor-channel.md) | | `{77DB6B28-785E-4641-B840-42F0F06A11FC}` | reserved — offered, but rejects the handshake with `-NO` | n/a | See [Handshake](03-handshake.md). @@ -127,8 +128,10 @@ Even with no requests outstanding, an open MIDI3 stream delivers: number `$4E`, eleven 14-bit values: the tuner strobe and the output meters ([Realtime status & meters](07-realtime-status.md)); - the **beat pulse** at page `$7C`, number 0, toggling 0/16383 with the tempo; -- a **`$01` echo of every parameter the device changes**, including changes made - at the front panel — which is what lets a client keep a consistent state model; +- a **`$01` push for parameters the device changes on its own**, such as a knob + turned at the front panel — a client's own `$01` writes are *not* echoed, so + confirming one means asking for it back with `$41` + ([Control model](08-control-model.md)); - on a rig change, an **unprompted dump of the entire new rig**: name, author, comment, amp, cabinet, every effect-slot type and state, and the rig settings. diff --git a/docs/10-versioning-and-compatibility.md b/docs/10-versioning-and-compatibility.md index 89299f7..a64934d 100644 --- a/docs/10-versioning-and-compatibility.md +++ b/docs/10-versioning-and-compatibility.md @@ -13,7 +13,8 @@ Every protocol constant and lookup table lives once, in [`../spec`](../spec): the discovery poll. - `parameters.toml` — the page/number parameter maps, effect slots, string tags, and well-known addresses. -- `effect-types.toml` — the effect Type value → name table. +- `effect-types.toml` — the effect Type value → name table, and the category + blocks the values are allocated in. - `controls.toml` — the Control Change vocabulary. - `meters.toml` — the realtime status / meter block. diff --git a/docs/README.md b/docs/README.md index 1ad49e8..d723a8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ control. 3. [Handshake](03-handshake.md) — TCP protocol-GUID negotiation and session preamble 4. [MIDI3 framing](04-midi3-framing.md) — how MIDI is carried over the stream 5. [SysEx / NRPN dialect](05-sysex-nrpn.md) — the parameter message grammar -6. [The CBOR channel](06-cbor-channel.md) — the native control channel (experimental) +6. [The CBOR channel](06-cbor-channel.md) — native control channel; the current bank/rig snapshot 7. [Realtime status & meters](07-realtime-status.md) — the ~20 Hz status block 8. [Control model](08-control-model.md) — CC vs NRPN, and the device model 9. [Parameter registry](09-parameter-registry.md) — typed parameter descriptors diff --git a/python/README.md b/python/README.md index f0eda78..f11166c 100644 --- a/python/README.md +++ b/python/README.md @@ -121,7 +121,6 @@ async def main(): asyncio.run(main()) ``` - ## Layout | Module | What it does | @@ -130,6 +129,7 @@ asyncio.run(main()) | `libkp.discovery` | Async UDP broadcast discovery (`discover`, `find_first`). | | `libkp.session` | TCP connect plus the line-based protocol-selection handshake. | | `libkp.midi3` | The 4-byte stream framing (`Unframer`, `frame`). | +| `libkp.cbor` | The native CBOR channel: codec plus `fetch_state_snapshot`, the state-dump read of the current bank/rig. | | `libkp.nrpn` | Kemper SysEx/NRPN builders and parsers, plus the beacon. | | `libkp.control` | The 7-bit CC / PC / Bank Select vocabulary. | | `libkp.params` | Offline `page/number → name` lookups. | @@ -163,16 +163,26 @@ Commands split into two groups: - **Parameters** (`set_gain`, `set_rig_volume`, `set_main_volume`, `set_monitor_volume`, `set_effect_enabled`, `set_effect_mix`, `set_tempo_bpm`, `set_param`) go out as 14-bit NRPN `$01` Single Parameter Changes. The device - echoes them on the same stream the model ingests, so `model.state()` stays - consistent. + applies them silently and does not echo them, so follow a set with + `request_param()` when `model.state()` should confirm the new value. - **Actions** (`select_rig`, `rig_up`, `rig_down`, `bank`, `tap_tempo`, `tuner_mode`, `morph_button`, `freeze`, `rotary_fast`, `delay_infinity`, `effect_button`, the pedals, and `send_control`) are momentary 7-bit Control Changes and are not reflected in state. -`refresh_rig()` is neither: it only issues read-only value *requests* (the rig -strings and each effect slot's Type/On-Off) and is what `connect()` runs as the -initial sync. +`refresh_rig()` and `refresh_bank()` are neither: they only issue read-only value +*requests* — the rig strings and each effect slot's Type/On-Off, and the current +bank's five-slot rig/amp/cabinet name preview — and are what `connect()` runs as +the initial sync. The bank preview lands in `state.bank`; the master-volume knob +reads back through `state.output.master_volume`. + +The device's **current bank and rig position** is not on the MIDI3 stream at all. +`await fetch_state_snapshot(ip)` reads it over the native CBOR channel (docs/06): +it opens its own short-lived session, triggers the device's state dump, and +returns the 0-based `current_bank` / `current_rig_slot`. Run it at boot *before* +`DeviceModel.connect` — the device dislikes concurrent connections — then feed +the result in with `model.set_current_position(bank, slot)`, which folds the +indices into `state.current_bank` / `state.current_rig_slot`. ## Decoding without a device diff --git a/python/src/libkp/__init__.py b/python/src/libkp/__init__.py index a2a7c79..1582699 100644 --- a/python/src/libkp/__init__.py +++ b/python/src/libkp/__init__.py @@ -35,6 +35,13 @@ async def main(): from __future__ import annotations from ._generated import SPEC_VERSION +from .cbor import ( + StateSnapshot, + extract_snapshot, + fetch_state_snapshot, + param_write, + state_dump_request, +) from .control import ( Control, ModuleSlot, @@ -82,6 +89,7 @@ async def main(): EFFECT_SLOTS, describe, describe_numeric, + effect_category_name, effect_slot_page, effect_type_name, page_name, @@ -91,6 +99,8 @@ async def main(): from .protocol import PORT, TagStream, build_poll_request from .registry import ParamDescriptor, ParamKind, descriptor, format_value from .session import ( + CONNECTION_COOLDOWN, + PROTOCOL_CBOR_CONTROL, PROTOCOL_MIDI3_STREAM, PROTOCOL_REQUEST_RESPONSE, HandshakeOutcome, @@ -99,10 +109,14 @@ async def main(): from .state import ( Amp, ApplyOutcome, + Bank, + BankPreview, + BankSlot, BeatPulse, Cabinet, Connected, Connection, + CurrentPosition, DeviceEvent, DeviceState, Disconnected, @@ -140,6 +154,8 @@ async def main(): "HandshakeOutcome", "PROTOCOL_MIDI3_STREAM", "PROTOCOL_REQUEST_RESPONSE", + "PROTOCOL_CBOR_CONTROL", + "CONNECTION_COOLDOWN", "Unframer", "frame", "is_kemper_sysex", @@ -172,6 +188,7 @@ async def main(): "page_name", "string_tag_name", "effect_type_name", + "effect_category_name", "effect_slot_page", "describe", "describe_numeric", @@ -189,10 +206,13 @@ async def main(): "Effect", "Tuner", "Output", + "BankSlot", + "Bank", "ApplyOutcome", "DeviceEvent", "RigChanged", "StringTag", + "BankPreview", "EffectChanged", "ParamChanged", "Status", @@ -202,9 +222,16 @@ async def main(): "TunerDeviance", "TunerNote", "RenderedString", + "CurrentPosition", "Connected", "Disconnected", "DeviceModel", + # cbor state-dump snapshot + "StateSnapshot", + "extract_snapshot", + "fetch_state_snapshot", + "param_write", + "state_dump_request", # errors "LibKPError", "ParseError", diff --git a/rust/README.md b/rust/README.md index 158caf7..2921c1c 100644 --- a/rust/README.md +++ b/rust/README.md @@ -35,16 +35,16 @@ while let Ok(state) = snapshots.recv().await { # Ok(()) # } ``` - ## Layout | Module | What it does | |---|---| | `generated` | **Data only** — constants and lookup tables emitted from the shared `spec/` by `codegen/generate.py`. Never edited by hand. | | `protocol` | The tag-stream wire encoding and the `DSCV` discovery poll packet. | -| `discovery` | Async UDP broadcast discovery (`discover`, `find_first`). | +| `discovery` | Async UDP broadcast discovery (`DiscoveryPort`, `discover`, `find_first`). | | `session` | TCP connect, the protocol-list handshake, and the stream preamble. | | `midi3` | The 4-byte stream framing (`Unframer`, `frame`). | +| `cbor` | The native CBOR channel: codec plus `StateSnapshot::fetch`, the state-dump read of the current bank/rig. | | `nrpn` | Kemper SysEx/NRPN builders and parsers (14-bit values, string tags, extended strings, the beacon). | | `control` | The 7-bit CC / Program Change / Bank Select vocabulary (`Control`). | | `params` | Offline `page/number → name` lookups over the generated tables. | @@ -70,8 +70,9 @@ than snapshots. ### Parameters vs actions - **Parameters** (`set_gain`, `set_rig_volume`, `set_effect_enabled`, …) go out - as 14-bit NRPN Single Parameter Changes. The device echoes them back on the - same stream, so the snapshot stays consistent. + as 14-bit NRPN Single Parameter Changes. The device applies them silently and + does not echo them, so follow a set with `request_param` when the snapshot + should confirm the new value. - **Actions** (`tap_tempo`, `rig_up`, `select_rig`, `tuner_mode`, …) go out as 7-bit Control Changes. They are momentary and carry no read-back, so they are not reflected in state. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index eefe5e3..2a14f9f 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -8,6 +8,8 @@ //! - [`discovery`] — UDP broadcast discovery ([`discovery::discover`]). //! - [`session`] — TCP connect + the line-based protocol handshake. //! - [`midi3`] — the 4-byte stream framing that carries MIDI over the session. +//! - [`cbor`] — the device's native CBOR channel and the state-dump snapshot +//! ([`cbor::StateSnapshot::fetch`]) that reads the current bank and rig slot. //! - [`nrpn`] — Kemper SysEx/NRPN builders and parsers. //! - [`control`] — the 7-bit CC / PC / Bank Select control vocabulary. //! - [`params`] / [`registry`] — offline name and descriptor lookups. @@ -35,6 +37,7 @@ //! # } //! ``` +pub mod cbor; pub mod control; pub mod discovery; pub mod error; @@ -55,6 +58,7 @@ pub const PORT: u16 = generated::PORT; /// The version of the shared protocol spec this crate was generated against. pub const SPEC_VERSION: &str = generated::SPEC_VERSION; +pub use cbor::{StateSnapshot, extract_snapshot, param_write, state_dump_request}; pub use control::{Control, ModuleSlot, program_change, slot_enable_cc}; pub use discovery::{Options, Reply, discover, find_first}; pub use error::{DiscoverError, ParseError, SessionError}; @@ -67,5 +71,8 @@ pub use nrpn::{ }; pub use protocol::{DISCOVERY_PORT, DSCV_HEADER, TagStream, build_poll_request}; pub use registry::{ParamDescriptor, ParamKind, descriptor, format_value}; -pub use session::{HandshakeOutcome, PROTOCOL_MIDI3_STREAM, PROTOCOL_REQUEST_RESPONSE, Session}; +pub use session::{ + CONNECTION_COOLDOWN, HandshakeOutcome, PROTOCOL_CBOR_CONTROL, PROTOCOL_MIDI3_STREAM, + PROTOCOL_REQUEST_RESPONSE, Session, +}; pub use state::{Amp, Cabinet, Connection, DeviceState, Effect, Output, Rig, Tuner}; diff --git a/spec/version.toml b/spec/version.toml index b71c02d..82107ab 100644 --- a/spec/version.toml +++ b/spec/version.toml @@ -2,4 +2,4 @@ # asserts it in its conformance suite; CI cross-checks that all three languages # report the same number. Bump it whenever spec/ changes in a way that alters # generated data or wire behavior. -spec_version = "0.1.0" +spec_version = "0.5.0" diff --git a/swift/README.md b/swift/README.md index 8ff9869..5ac3693 100644 --- a/swift/README.md +++ b/swift/README.md @@ -7,8 +7,9 @@ Pure Swift: Foundation plus the `Network` framework for TCP/UDP. No SwiftPM dependencies, no C bridging, no code generation at build time. ```sh -swift build # the library and the example +swift build # the library and the examples swift build --product meters # just the example +swift run MetersApp # the SwiftUI app example swift test # unit tests + the shared conformance suite ``` @@ -31,6 +32,7 @@ Requires Swift 6.0 or newer and macOS 13+. | `Sources/LibKP/State.swift` | The state tree and its decode routing | | `Sources/LibKP/DeviceModel.swift` | The `actor` that owns the session and publishes state | | `Sources/meters/main.swift` | A live full-screen terminal view | +| `Sources/MetersApp/` | The same dashboard as a native SwiftUI macOS app | Everything the conformance vectors exercise — framing, builders, parsers, name lookups, and `DeviceState.apply` — is pure and imports no networking, so it is @@ -78,7 +80,6 @@ if let device = try await Discovery.findFirst(listenFor: 3) { print(device.name ?? "unnamed", device.host) } ``` - ### Fast vs slow state State is classified into two lanes. **FAST** is the meter `RealtimeStatus` @@ -89,9 +90,11 @@ block, the beat pulse and tuner deviance — high-rate data best polled through ### Parameters vs actions - **Parameters** (`setGain`, `setEffectEnabled`, `setTempoBpm`, `setParam`, …) - are settable values the device reports back. They go out as 14-bit NRPN `$01` - Single Parameter Changes, so the device echoes the change on the same stream - the model ingests and the snapshot stays consistent. + are settable values the device stores. They go out as 14-bit NRPN `$01` + Single Parameter Changes; the device applies the write silently and does *not* + echo it back, so follow a set with `requestParam` when the snapshot should + confirm the new value — the `$41` reply flows through normal ingest, which is + what `MetersApp` does when you click an effect block. - **Actions** (`tapTempo`, `rigUp`, `selectRig`, `send(control:)`, …) are momentary presses and live expression. They go out as 7-bit Control Change messages and are *not* reflected in state. @@ -122,6 +125,24 @@ tuner strobe with an in-tune/sharp/flat verdict derived from the phase drift rate; level bars with peak-hold; a tempo pulse; and the last parameter seen. Ctrl-C restores the terminal. +## The `MetersApp` example + +The same dashboard as a native SwiftUI macOS app: the rig name, author and +tempo; the amp and cabinet; the eight effect blocks with their on/off state, +effect type, category and mix; the tuner strobe with its in-tune/sharp/flat +verdict; level meters with peak-hold; and the tempo pulse. Clicking a +signal-chain block toggles that effect on or off — the one write it performs; +everything else it sends is a value request. + +```sh +swift run MetersApp +``` + +It discovers a device on the LAN by default. Settings (⌘,) switches to a manual +IP for a device discovery cannot reach, and toggles the level list between the +three bar meters and all eleven raw fields. When the device drops the +connection the app says so and reconnects on its own. + ## Tests `swift test` runs two things: @@ -138,6 +159,8 @@ Ctrl-C restores the terminal. ## Provenance Parameter maps, the SysEx/NRPN grammar, effect types, string tags, the CC map -and the beacon come from the Kemper MIDI Parameter Documentation and PySwitch. -Discovery, the handshake, the stream framing, session encapsulation and the -realtime status field identities were established by observed experimentation. +and the beacon come from the Kemper MIDI Parameter Documentation and PySwitch; +the effect-type category blocks are inferred from the value-range structure of +its Appendix B. Discovery, the handshake, the stream framing, session +encapsulation and the realtime status field identities were established by +observed experimentation. From 80d3c33e935d6f200b682f8c11b28b41117bb3a9 Mon Sep 17 00:00:00 2001 From: Aaron Gotwalt Date: Sat, 22 Aug 2026 16:12:02 -0700 Subject: [PATCH 5/5] Take the discovery port exclusively and fail fast if it is held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery bound UDP 5727 with SO_REUSEPORT, so it shared the port happily with anything else already listening. That is the wrong default here. The device answers a poll only on 5727 — it ignores the poll's source port — and when several sockets are bound to one UDP port the kernel delivers each datagram to exactly one of them. A second listener does not see a copy of the reply; it takes it. The practical failure: with Kemper's Rig Manager running, discovery returned no devices while the network and the Profiler were both healthy, and the app reported "No Profiler found on the network" — which sends you looking at the network rather than at the other program holding the port. So bind without SO_REUSEADDR or SO_REUSEPORT. If the port is free the bind succeeds and nothing else can take it while the socket is open, so replies cannot be stolen mid-session. If it is already held the bind fails with EADDRINUSE, which is reported as a port conflict naming the likely holder rather than being worked around. Each implementation gains a `DiscoveryPort` handle, acquired before a session and held for its lifetime, that can be polled repeatedly — a long-running client re-polls to notice devices appearing and disappearing without ever letting go of the port. `discover` and `find_first` still acquire, poll once and release, so one-shot callers are unchanged. Swift needed a transport change to allow this at all: it used one NWListener plus one NWConnection per target, all co-bound to 5727, which requires allowLocalEndpointReuse — precisely the SO_REUSEPORT that causes the bug. Network.framework cannot decline to share a port, so discovery is now a single exclusively-bound BSD socket. That also removes a latent mislabel, since recvfrom reports the true source address where the sender connection reported the broadcast address it was created with. MetersApp holds the port across its session and surfaces the conflict message. Manual mode connects straight over TCP and never polls, so it does not claim the port. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013XSn94RpApv9AYMQABGM8S --- docs/02-discovery.md | 32 ++ python/README.md | 15 + python/src/libkp/__init__.py | 5 +- python/src/libkp/discovery.py | 120 ++++++-- python/src/libkp/errors.py | 26 ++ python/tests/test_discovery.py | 52 ++++ rust/README.md | 13 + rust/src/discovery.rs | 243 ++++++++++----- rust/src/error.rs | 19 ++ rust/src/lib.rs | 2 +- swift/README.md | 16 +- swift/Sources/LibKP/Discovery.swift | 351 ++++++++++++++++------ swift/Sources/LibKP/Errors.swift | 18 ++ swift/Sources/MetersApp/DeviceStore.swift | 38 ++- swift/Tests/LibKPTests/UnitTests.swift | 60 ++++ 15 files changed, 811 insertions(+), 199 deletions(-) diff --git a/docs/02-discovery.md b/docs/02-discovery.md index 8455350..40bba06 100644 --- a/docs/02-discovery.md +++ b/docs/02-discovery.md @@ -4,6 +4,38 @@ Profilers are found by UDP broadcast on port **5727**. The client sends a fixed 34-byte poll; every Profiler on the LAN answers with a description of itself. Both the poll and the reply use the same small serialization, **TagStream**. +## Owning the port + +A client must take UDP 5727 **exclusively**, and hold it for as long as the +session is active. + +This is not a stylistic preference. The device answers a poll only on port 5727 — +it ignores the poll's source port, so listening anywhere else hears nothing — and +when several sockets are bound to one UDP port the kernel delivers each arriving +datagram to *exactly one* of them. A second listener therefore does not observe a +copy of the reply; it takes it. Two programs sharing the port turn discovery into +a coin flip, and the one that loses reports "no device found" while the network +and the Profiler are both perfectly healthy. + +So bind without `SO_REUSEADDR` and without `SO_REUSEPORT`: + +- **If the port is free**, the exclusive bind succeeds and no other process can + take it while the socket is open — replies cannot be stolen mid-session. +- **If the port is already held**, the bind fails with `EADDRINUSE`, and that is + the correct moment to stop and say so. Report it as a port conflict naming the + likely holder; do not fall back to a shared bind, which merely converts a clear + start-up error into an intermittent one. + +The usual holder on a desktop machine is other Kemper software: Rig Manager binds +UDP 5727 for its entire run, so it has to be quit before a client can discover. + +Implementations expose this as an owned handle — `DiscoveryPort` in all three — +acquired before the session and released when it ends. `poll` may be called on it +as often as needed, which is what a long-running client wants: re-poll to notice +Profilers appearing and disappearing without ever letting go of the port in +between. The one-shot `discover` / `find_first` helpers acquire, poll once and +release, and suit a CLI rather than a session. + ## TagStream TagStream is a length-prefixed field list with an optional 4-byte ASCII header. diff --git a/python/README.md b/python/README.md index f11166c..11e571a 100644 --- a/python/README.md +++ b/python/README.md @@ -121,6 +121,21 @@ async def main(): asyncio.run(main()) ``` + +Discovery needs UDP 5727 **exclusively** — the device replies only to that +port, and the kernel hands each reply to just one bound socket, so a second +listener steals replies rather than copying them. Acquiring it fails fast if +another program (Kemper's Rig Manager, typically) holds it. Hold a +`DiscoveryPort` across a session rather than re-acquiring per attempt; see +[Discovery](../docs/02-discovery.md#owning-the-port). + +```python +from libkp import DiscoveryPort + +with DiscoveryPort.acquire() as port: # raises PortUnavailableError if taken + replies = await port.poll() +``` + ## Layout | Module | What it does | diff --git a/python/src/libkp/__init__.py b/python/src/libkp/__init__.py index 1582699..d3f2266 100644 --- a/python/src/libkp/__init__.py +++ b/python/src/libkp/__init__.py @@ -49,7 +49,7 @@ async def main(): program_change, slot_enable_cc, ) -from .discovery import DiscoveryOptions, Reply, discover, find_first +from .discovery import DiscoveryOptions, DiscoveryPort, Reply, discover, find_first from .errors import ( CommandError, ConnectError, @@ -59,6 +59,7 @@ async def main(): FieldOverrunError, LibKPError, ParseError, + PortUnavailableError, ProtocolRejectedError, SessionError, TimeoutErrorLibKP, @@ -149,6 +150,7 @@ async def main(): "DiscoveryOptions", "Reply", "discover", + "DiscoveryPort", "find_first", "Session", "HandshakeOutcome", @@ -235,6 +237,7 @@ async def main(): # errors "LibKPError", "ParseError", + "PortUnavailableError", "TooShortError", "FieldOverrunError", "DiscoverError", diff --git a/python/src/libkp/discovery.py b/python/src/libkp/discovery.py index 25440fb..e4b26df 100644 --- a/python/src/libkp/discovery.py +++ b/python/src/libkp/discovery.py @@ -9,14 +9,15 @@ from __future__ import annotations import asyncio +import errno import socket from dataclasses import dataclass, field from . import _generated as gen -from .errors import DiscoverError, ParseError +from .errors import DiscoverError, ParseError, PortUnavailableError from .protocol import PLACEHOLDER_MAC, PORT, TagStream, build_poll_request -__all__ = ["Reply", "DiscoveryOptions", "discover", "find_first"] +__all__ = ["Reply", "DiscoveryOptions", "DiscoveryPort", "discover", "find_first"] #: The global IPv4 broadcast address. _GLOBAL_BROADCAST = "255.255.255.255" @@ -139,22 +140,35 @@ def broadcast_targets(extra: list[str] | None = None) -> list[str]: def _bind_broadcast_socket(port: int) -> socket.socket: - """Create a UDP socket suitable for broadcast discovery, bound to ``port``.""" + """Create a UDP socket for broadcast discovery, bound **exclusively** to ``port``. + + Neither ``SO_REUSEADDR`` nor ``SO_REUSEPORT`` is set, and that is deliberate. + The device answers a poll only on this port, and the kernel delivers each + arriving datagram to just one of the sockets bound to it — so a second + listener does not see a copy of the reply, it takes it. Sharing the port + therefore turns discovery into a coin flip rather than a redundancy. + + Refusing to share makes the conflict loud: acquiring the port fails with + :class:`PortUnavailableError` at start-up, and once acquired no other process + can take it for as long as the socket is open. + """ try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) except OSError as exc: raise DiscoverError(f"failed to create UDP socket: {exc}") from exc try: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - reuse_port = getattr(socket, "SO_REUSEPORT", None) - if reuse_port is not None: - sock.setsockopt(socket.SOL_SOCKET, reuse_port, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.setblocking(False) - sock.bind(("0.0.0.0", port)) except OSError as exc: sock.close() raise DiscoverError(f"failed to configure discovery socket: {exc}") from exc + try: + sock.bind(("0.0.0.0", port)) + except OSError as exc: + sock.close() + if exc.errno == errno.EADDRINUSE: + raise PortUnavailableError(port, exc) from exc + raise DiscoverError(f"failed to bind discovery socket: {exc}") from exc return sock @@ -175,22 +189,68 @@ async def _send_round( raise DiscoverError(f"failed to send discovery poll: {last_error}") -async def discover(options: DiscoveryOptions | None = None) -> list[Reply]: - """Broadcast the discovery poll and gather replies until the window ends. +class DiscoveryPort: + """Exclusive owner of the UDP discovery port. + + Acquire one before opening a session and keep it for the session's lifetime. + Holding it does two things: it guarantees every reply reaches *this* process + — no other socket can bind the port while it is open — and it fails loudly, + up front, if the port is already taken, rather than letting discovery come up + empty later on. See :class:`~libkp.errors.PortUnavailableError`. + + :meth:`poll` may be called as often as needed on a held port, which is what a + long-running client wants: the device set is re-polled to notice Profilers + appearing and disappearing, without ever letting go of the port in between. + + Usable as a context manager:: - Returns one :class:`Reply` per distinct source IP (last payload wins). The - device answers from a fresh ephemeral port each poll, so keying by full - source address would report one "device" per reply. + with DiscoveryPort.acquire() as port: + replies = await port.poll() """ - opts = options or DiscoveryOptions() - loop = asyncio.get_running_loop() - poll = build_poll_request(opts.mac) - targets = broadcast_targets(opts.extra_targets) - sock = _bind_broadcast_socket(opts.port) - replies: dict[str, Reply] = {} - try: - await _send_round(loop, sock, poll, targets, opts.port) + __slots__ = ("_sock", "port") + + def __init__(self, sock: socket.socket, port: int) -> None: + self._sock = sock + #: The port held. + self.port = port + + @classmethod + def acquire(cls, port: int = PORT) -> DiscoveryPort: + """Take exclusive ownership of ``port``. + + :raises PortUnavailableError: if another process already holds it. + """ + return cls(_bind_broadcast_socket(port), port) + + def close(self) -> None: + """Release the port. Safe to call more than once.""" + self._sock.close() + + def __enter__(self) -> DiscoveryPort: + return self + + def __exit__(self, *_exc: object) -> None: + self.close() + + async def poll(self, options: DiscoveryOptions | None = None) -> list[Reply]: + """Broadcast the poll and gather replies until the listen window ends. + + Returns one :class:`Reply` per distinct source IP (last payload wins). The + device answers from a fresh ephemeral port each poll, so keying by full + source address would report one "device" per reply. + + ``options.port`` is ignored — the poll goes out on the held port, since + that is the one the device replies to. + """ + opts = options or DiscoveryOptions() + loop = asyncio.get_running_loop() + poll = build_poll_request(opts.mac) + targets = broadcast_targets(opts.extra_targets) + sock = self._sock + + replies: dict[str, Reply] = {} + await _send_round(loop, sock, poll, targets, self.port) deadline = loop.time() + opts.listen_for next_poll = loop.time() + opts.repeat_every @@ -213,12 +273,22 @@ async def discover(options: DiscoveryOptions | None = None) -> list[Reply]: replies[addr[0]] = Reply(addr=addr, payload=payload) if loop.time() >= next_poll: - await _send_round(loop, sock, poll, targets, opts.port) + await _send_round(loop, sock, poll, targets, self.port) next_poll = loop.time() + opts.repeat_every - finally: - sock.close() - return [replies[ip] for ip in sorted(replies)] + return [replies[ip] for ip in sorted(replies)] + + +async def discover(options: DiscoveryOptions | None = None) -> list[Reply]: + """Acquire the discovery port, poll once, and release it. + + A convenience for one-shot callers such as a CLI. Anything that goes on to + open a session should hold a :class:`DiscoveryPort` across the session + instead, so no other process can take the port midway through. + """ + opts = options or DiscoveryOptions() + with DiscoveryPort.acquire(opts.port) as port: + return await port.poll(opts) async def find_first(listen_for: float = 3.0) -> Reply | None: diff --git a/python/src/libkp/errors.py b/python/src/libkp/errors.py index 6770b6a..ec9c454 100644 --- a/python/src/libkp/errors.py +++ b/python/src/libkp/errors.py @@ -66,6 +66,32 @@ class DiscoverError(LibKPError): """Discovery could not be carried out (socket, bind, or send failure).""" +class PortUnavailableError(DiscoverError): + """The discovery port could not be taken exclusively. + + libkp requires sole ownership of UDP :data:`~libkp.protocol.PORT` for as long + as a session is active. The device answers a poll only on that port, and the + operating system hands each reply to exactly one of the sockets bound to it, + so a second listener silently swallows replies rather than duplicating them. + Binding exclusively turns that into this error at start-up instead of a + device that intermittently "cannot be found". + + The usual holder is other Kemper software on the same machine — Rig Manager + keeps the port open for its whole run — which must be quit first. + """ + + def __init__(self, port: int, cause: OSError) -> None: + super().__init__( + f"UDP port {port} is already held by another application. libkp needs " + f"exclusive use of it while a session is active; quit any other Kemper " + f"software (Rig Manager keeps this port open) and try again. ({cause})" + ) + #: The port that could not be acquired. + self.port = port + #: The underlying :class:`OSError` from :func:`socket.socket.bind`. + self.cause = cause + + # --------------------------------------------------------------------------- # Session # --------------------------------------------------------------------------- diff --git a/python/tests/test_discovery.py b/python/tests/test_discovery.py index 21a4a33..78c5bc6 100644 --- a/python/tests/test_discovery.py +++ b/python/tests/test_discovery.py @@ -4,13 +4,17 @@ import asyncio +import pytest + from libkp.discovery import ( DiscoveryOptions, + DiscoveryPort, Reply, _is_poll, broadcast_targets, discover, ) +from libkp.errors import PortUnavailableError from libkp.protocol import build_poll_request, push_field # A synthetic reply payload in the shape the device answers with. @@ -69,3 +73,51 @@ async def scenario(): return await discover(options) assert asyncio.run(scenario()) == [] + + +def test_the_discovery_port_is_held_exclusively(): + """A second acquire must fail rather than quietly share the port. + + Sharing is the failure this guards against: the kernel gives an arriving + reply to exactly one bound socket, so a co-bound listener steals replies + instead of duplicating them. + """ + with DiscoveryPort.acquire(54322) as first: + assert first.port == 54322 + with pytest.raises(PortUnavailableError) as caught: + DiscoveryPort.acquire(54322) + assert caught.value.port == 54322 + assert "exclusive" in str(caught.value) + + +def test_releasing_the_port_lets_it_be_acquired_again(): + DiscoveryPort.acquire(54323).close() + DiscoveryPort.acquire(54323).close() # no leak: the first release freed it + + +def test_closing_a_port_twice_is_harmless(): + port = DiscoveryPort.acquire(54324) + port.close() + port.close() + + +def test_a_held_port_can_be_polled_repeatedly(): + """A long-running client re-polls to notice devices coming and going, + without ever releasing the port in between.""" + + async def scenario(): + options = DiscoveryOptions(listen_for=0.1, repeat_every=0.05, port=54325) + with DiscoveryPort.acquire(54325) as port: + return [await port.poll(options), await port.poll(options)] + + assert asyncio.run(scenario()) == [[], []] + + +def test_discover_releases_the_port_it_acquired(): + """The one-shot helper must not leave the port held behind it.""" + + async def scenario(): + await discover(DiscoveryOptions(listen_for=0.1, repeat_every=0.05, port=54326)) + + asyncio.run(scenario()) + DiscoveryPort.acquire(54326).close() # would raise if discover leaked it diff --git a/rust/README.md b/rust/README.md index 2921c1c..30509cd 100644 --- a/rust/README.md +++ b/rust/README.md @@ -35,6 +35,19 @@ while let Ok(state) = snapshots.recv().await { # Ok(()) # } ``` + +Discovery needs UDP 5727 **exclusively** — the device replies only to that +port, and the kernel hands each reply to just one bound socket, so a second +listener steals replies rather than copying them. Acquiring it fails fast if +another program (Kemper's Rig Manager, typically) holds it. Hold a +`DiscoveryPort` across a session rather than re-acquiring per attempt; see +[Discovery](../docs/02-discovery.md#owning-the-port). + +```rust +let port = libkp::DiscoveryPort::acquire()?; // Err(PortUnavailable) if taken +let replies = port.poll(&libkp::Options::default()).await?; +``` + ## Layout | Module | What it does | diff --git a/rust/src/discovery.rs b/rust/src/discovery.rs index 6f29ea1..45a94d5 100644 --- a/rust/src/discovery.rs +++ b/rust/src/discovery.rs @@ -78,84 +78,142 @@ impl Default for Options { } } -/// Broadcast the discovery poll and gather replies until the listen window ends. +/// Exclusive owner of the UDP discovery port. /// -/// Returns one [`Reply`] per distinct source address (last payload wins). -pub async fn discover(opts: &Options) -> Result, DiscoverError> { - let socket = bind_broadcast_socket(DISCOVERY_PORT)?; - let poll = build_poll_request(&opts.mac); - let targets = broadcast_targets(&opts.extra_targets)?; - - if opts.verbose { - eprintln!("poll targets ({}):", targets.len()); - for t in &targets { - eprintln!(" {t}:{DISCOVERY_PORT}"); - } +/// Acquire one before opening a session and keep it for the session's lifetime. +/// Holding it does two things: it guarantees every reply reaches *this* process — +/// no other socket can bind the port while it is open — and it fails loudly, up +/// front, if the port is already taken, rather than letting discovery quietly come +/// up empty later. See [`DiscoverError::PortUnavailable`]. +/// +/// [`poll`](Self::poll) may be called as often as needed on a held port, which is +/// what a long-running client wants: re-poll to notice Profilers appearing and +/// disappearing, without ever letting go of the port in between. +/// +/// ```no_run +/// # async fn run() -> Result<(), libkp::DiscoverError> { +/// let port = libkp::DiscoveryPort::acquire()?; +/// let replies = port.poll(&libkp::Options::default()).await?; +/// # Ok(()) } +/// ``` +/// +/// The port is released when the value is dropped. +#[derive(Debug)] +pub struct DiscoveryPort { + socket: UdpSocket, + port: u16, +} + +impl DiscoveryPort { + /// Take exclusive ownership of the standard discovery port. + /// + /// Returns [`DiscoverError::PortUnavailable`] if another process holds it. + pub fn acquire() -> Result { + Self::acquire_on(DISCOVERY_PORT) + } + + /// Take exclusive ownership of `port`. Mostly useful for tests. + pub fn acquire_on(port: u16) -> Result { + Ok(Self { + socket: bind_broadcast_socket(port)?, + port, + }) } - // Fire an initial round immediately. - send_round(&socket, &poll, &targets).await?; - - let deadline = Instant::now() + opts.listen_for; - // `checked_add` guards against a very large `repeat_every`; on overflow we - // simply never re-poll within the window. - let repoll_at = |from: Instant| from.checked_add(opts.repeat_every).unwrap_or(deadline); - let mut next_poll = repoll_at(Instant::now()); - // Keyed by source IP: the device answers from a fresh ephemeral port each - // poll, so keying by full SocketAddr would report one "device" per reply. - let mut replies: BTreeMap)> = BTreeMap::new(); - let mut buf = vec![0u8; 2048]; - - loop { - let now = Instant::now(); - if now >= deadline { - break; + /// The port held. + pub fn port(&self) -> u16 { + self.port + } + + /// Broadcast the poll and gather replies until the listen window ends. + /// + /// Returns one [`Reply`] per distinct source address (last payload wins). + pub async fn poll(&self, opts: &Options) -> Result, DiscoverError> { + let socket = &self.socket; + let poll = build_poll_request(&opts.mac); + let targets = broadcast_targets(&opts.extra_targets)?; + + if opts.verbose { + eprintln!("poll targets ({}):", targets.len()); + for t in &targets { + eprintln!(" {t}:{}", self.port); + } } - let recv_window = (deadline - now).min(next_poll.saturating_duration_since(now)); - - match timeout(recv_window, socket.recv_from(&mut buf)).await { - Ok(Ok((n, from))) => { - let pkt = &buf[..n]; - if opts.verbose { - eprintln!( - "recv {n} bytes from {from}: {}", - crate::fmt::hex_inline(pkt) - ); - } - // Ignore any POLL packet — our own echoed back on the shared - // reuse-port socket, or another client's broadcast — since - // recording one as a "device" would return a random client's IP. - if !is_poll(pkt) { - replies.insert(from.ip(), (from, pkt.to_vec())); + + // Fire an initial round immediately. + send_round(socket, &poll, &targets, self.port).await?; + + let deadline = Instant::now() + opts.listen_for; + // `checked_add` guards against a very large `repeat_every`; on overflow we + // simply never re-poll within the window. + let repoll_at = |from: Instant| from.checked_add(opts.repeat_every).unwrap_or(deadline); + let mut next_poll = repoll_at(Instant::now()); + // Keyed by source IP: the device answers from a fresh ephemeral port each + // poll, so keying by full SocketAddr would report one "device" per reply. + let mut replies: BTreeMap)> = BTreeMap::new(); + let mut buf = vec![0u8; 2048]; + + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + let recv_window = (deadline - now).min(next_poll.saturating_duration_since(now)); + + match timeout(recv_window, socket.recv_from(&mut buf)).await { + Ok(Ok((n, from))) => { + let pkt = &buf[..n]; + if opts.verbose { + eprintln!( + "recv {n} bytes from {from}: {}", + crate::fmt::hex_inline(pkt) + ); + } + // Ignore any POLL packet — our own echoed back, or another + // client's broadcast — since recording one as a "device" + // would return a random client's IP. + if !is_poll(pkt) { + replies.insert(from.ip(), (from, pkt.to_vec())); + } } + Ok(Err(e)) => return Err(DiscoverError::Recv(e)), + Err(_elapsed) => {} // window expired; fall through to maybe re-poll } - Ok(Err(e)) => return Err(DiscoverError::Recv(e)), - Err(_elapsed) => {} // window expired; fall through to maybe re-poll - } - if Instant::now() >= next_poll { - send_round(&socket, &poll, &targets).await?; - next_poll = repoll_at(Instant::now()); + if Instant::now() >= next_poll { + send_round(socket, &poll, &targets, self.port).await?; + next_poll = repoll_at(Instant::now()); + } } + + Ok(replies + .into_values() + .map(|(from, payload)| Reply { from, payload }) + .collect()) } +} - Ok(replies - .into_values() - .map(|(from, payload)| Reply { from, payload }) - .collect()) +/// Acquire the discovery port, poll once, and release it. +/// +/// A convenience for one-shot callers such as a CLI. Anything that goes on to open +/// a session should hold a [`DiscoveryPort`] across the session instead, so no +/// other process can take the port midway through. +pub async fn discover(opts: &Options) -> Result, DiscoverError> { + DiscoveryPort::acquire()?.poll(opts).await } async fn send_round( socket: &UdpSocket, poll: &[u8], targets: &[Ipv4Addr], + port: u16, ) -> Result<(), DiscoverError> { // A single unreachable target (e.g. global broadcast denied by a firewall) // should not abort the whole sweep; only fail if every send failed. let mut sent = 0usize; let mut last_err: Option = None; for ip in targets { - let addr = SocketAddr::V4(SocketAddrV4::new(*ip, DISCOVERY_PORT)); + let addr = SocketAddr::V4(SocketAddrV4::new(*ip, port)); match socket.send_to(poll, addr).await { Ok(_) => sent += 1, Err(e) => last_err = Some(DiscoverError::Send { addr, source: e }), @@ -176,24 +234,19 @@ pub async fn find_first(listen_for: Duration) -> Result, DiscoverE Ok(discover(&opts).await?.into_iter().next()) } -/// Create a UDP socket suitable for broadcast discovery, bound to `port`. +/// Create a UDP socket for broadcast discovery, bound **exclusively** to `port`. +/// +/// Neither `SO_REUSEADDR` nor `SO_REUSEPORT` is set, and that is deliberate. The +/// device answers a poll only on this port, and the kernel delivers each arriving +/// datagram to just one of the sockets bound to it — a second listener does not +/// see a copy of the reply, it takes it. Sharing the port would make discovery a +/// coin flip; refusing to share makes the clash loud instead, as +/// [`DiscoverError::PortUnavailable`], and holds the port against all comers for +/// as long as the socket is open. fn bind_broadcast_socket(port: u16) -> Result { let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) .map_err(DiscoverError::SocketCreate)?; - socket - .set_reuse_address(true) - .map_err(|source| DiscoverError::SocketOption { - option: "SO_REUSEADDR", - source, - })?; - #[cfg(unix)] - socket - .set_reuse_port(true) - .map_err(|source| DiscoverError::SocketOption { - option: "SO_REUSEPORT", - source, - })?; socket .set_broadcast(true) .map_err(|source| DiscoverError::SocketOption { @@ -210,9 +263,12 @@ fn bind_broadcast_socket(port: u16) -> Result { let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port); socket .bind(&SockAddr::from(bind_addr)) - .map_err(|source| DiscoverError::Bind { - addr: bind_addr, - source, + .map_err(|source| match source.kind() { + std::io::ErrorKind::AddrInUse => DiscoverError::PortUnavailable { port, source }, + _ => DiscoverError::Bind { + addr: bind_addr, + source, + }, })?; let std_sock: std::net::UdpSocket = socket.into(); @@ -278,4 +334,45 @@ mod tests { assert!(t.contains(&Ipv4Addr::BROADCAST)); assert!(t.contains(&Ipv4Addr::new(10, 0, 0, 7))); } + + /// A second acquire must fail rather than quietly share the port. Sharing is + /// the failure this guards against: the kernel gives an arriving reply to + /// exactly one bound socket, so a co-bound listener steals replies instead of + /// duplicating them. + #[tokio::test] + async fn the_port_is_held_exclusively() { + let first = DiscoveryPort::acquire_on(54341).expect("first acquire"); + assert_eq!(first.port(), 54341); + + match DiscoveryPort::acquire_on(54341) { + Err(DiscoverError::PortUnavailable { port, .. }) => { + assert_eq!(port, 54341); + } + other => panic!("expected PortUnavailable, got {other:?}"), + } + } + + #[tokio::test] + async fn releasing_the_port_lets_it_be_acquired_again() { + drop(DiscoveryPort::acquire_on(54342).expect("first acquire")); + // No leak: the first release freed it. + drop(DiscoveryPort::acquire_on(54342).expect("second acquire")); + } + + /// A long-running client re-polls to notice devices coming and going, without + /// ever releasing the port in between. Our own echoed poll must not be + /// mistaken for a device. + #[tokio::test] + async fn a_held_port_can_be_polled_repeatedly() { + let port = DiscoveryPort::acquire_on(54343).expect("acquire"); + let opts = Options { + listen_for: Duration::from_millis(100), + repeat_every: Duration::from_millis(50), + extra_targets: vec![Ipv4Addr::LOCALHOST], + ..Default::default() + }; + for _ in 0..2 { + assert!(port.poll(&opts).await.expect("poll").is_empty()); + } + } } diff --git a/rust/src/error.rs b/rust/src/error.rs index af827e3..1a170aa 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -22,6 +22,25 @@ pub enum DiscoverError { source: std::io::Error, }, + /// The discovery port is already held by another process. + /// + /// libkp takes UDP [`DISCOVERY_PORT`](crate::protocol::DISCOVERY_PORT) + /// exclusively for as long as a session is active. The device answers a poll + /// only on that port, and the kernel hands each arriving reply to exactly one + /// of the sockets bound to it — so a second listener takes replies rather + /// than seeing a copy. Binding exclusively surfaces the clash here, at + /// start-up, instead of as a device that intermittently cannot be found. + #[error( + "UDP port {port} is already held by another application. libkp needs \ + exclusive use of it while a session is active; quit any other Kemper \ + software (Rig Manager keeps this port open) and try again: {source}" + )] + PortUnavailable { + port: u16, + #[source] + source: std::io::Error, + }, + #[error("failed to enumerate local network interfaces: {0}")] Interfaces(#[source] std::io::Error), diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 2a14f9f..298af77 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -60,7 +60,7 @@ pub const SPEC_VERSION: &str = generated::SPEC_VERSION; pub use cbor::{StateSnapshot, extract_snapshot, param_write, state_dump_request}; pub use control::{Control, ModuleSlot, program_change, slot_enable_cc}; -pub use discovery::{Options, Reply, discover, find_first}; +pub use discovery::{DiscoveryPort, Options, Reply, discover, find_first}; pub use error::{DiscoverError, ParseError, SessionError}; pub use midi3::Unframer; pub use model::{ApplyOutcome, CommandError, DeviceEvent, DeviceModel, RealtimeStatus}; diff --git a/swift/README.md b/swift/README.md index 5ac3693..78f52fd 100644 --- a/swift/README.md +++ b/swift/README.md @@ -21,7 +21,7 @@ Requires Swift 6.0 or newer and macOS 13+. |---|---| | `Sources/LibKP/Generated.swift` | **Generated** constants and lookup tables — do not edit; see [`../codegen`](../codegen) | | `Sources/LibKP/Protocol.swift` | The discovery TagStream encoding and the poll packet | -| `Sources/LibKP/Discovery.swift` | UDP discovery over `NWListener` / `NWConnection` | +| `Sources/LibKP/Discovery.swift` | UDP discovery over an exclusively-bound BSD socket | | `Sources/LibKP/Session.swift` | TCP session, protocol handshake, stream preamble | | `Sources/LibKP/Inbox.swift` | The timed read buffer behind the session's async reads | | `Sources/LibKP/Midi3.swift` | Stream framing/unframing | @@ -80,6 +80,20 @@ if let device = try await Discovery.findFirst(listenFor: 3) { print(device.name ?? "unnamed", device.host) } ``` + +Discovery needs UDP 5727 **exclusively** — the device replies only to that +port, and the kernel hands each reply to just one bound socket, so a second +listener steals replies rather than copying them. Acquiring it fails fast if +another program (Kemper's Rig Manager, typically) holds it. Hold a +`DiscoveryPort` across a session rather than re-acquiring per attempt; see +[Discovery](../docs/02-discovery.md#owning-the-port). + +```swift +let port = try DiscoveryPort() // throws .portUnavailable if it is taken +defer { port.close() } +let replies = try await port.poll() +``` + ### Fast vs slow state State is classified into two lanes. **FAST** is the meter `RealtimeStatus` diff --git a/swift/Sources/LibKP/Discovery.swift b/swift/Sources/LibKP/Discovery.swift index 3f89a8e..63bf55f 100644 --- a/swift/Sources/LibKP/Discovery.swift +++ b/swift/Sources/LibKP/Discovery.swift @@ -1,5 +1,4 @@ import Foundation -import Network /// One raw reply from a candidate device. public struct DiscoveryReply: Sendable, Equatable { @@ -48,122 +47,282 @@ public struct DiscoveryOptions: Sendable { } } -/// UDP discovery: broadcast the poll, collect Profiler replies. +/// Exclusive owner of the UDP discovery port. /// -/// Discovery and the TCP session share one port (5727). The client broadcasts a -/// fixed poll packet; Profilers on the LAN reply on the same port. The exchange -/// was established by observed experimentation. -public enum Discovery { - /// Broadcast the discovery poll and gather replies until the listen window - /// ends. Returns one reply per distinct source address (last payload wins). - public static func discover( - _ options: DiscoveryOptions = DiscoveryOptions() - ) async throws -> [DiscoveryReply] { - let collector = ReplyCollector() - let poll = KemperProtocol.buildPollRequest(mac: options.mac) - let targets = broadcastTargets(extra: options.extraTargets) +/// Acquire one before opening a session and keep it for the session's lifetime. +/// Holding it does two things: it guarantees every reply reaches *this* process — +/// no other socket can bind the port while it is open — and it fails loudly, up +/// front, if the port is already taken, rather than letting discovery quietly come +/// up empty later. See ``DiscoverError/portUnavailable(port:)``. +/// +/// ``poll(_:)`` may be called as often as needed on a held port, which is what a +/// long-running client wants: re-poll to notice Profilers appearing and +/// disappearing, without ever letting go of the port in between. +/// +/// ```swift +/// let port = try DiscoveryPort() +/// defer { port.close() } +/// let replies = try await port.poll() +/// ``` +/// +/// This is a plain BSD socket rather than a `Network.framework` listener on +/// purpose. Sharing the port is the thing to prevent, and `NWParameters` offers +/// only `allowLocalEndpointReuse` — which sets `SO_REUSEPORT` and so invites +/// exactly the silent reply-stealing this type exists to rule out. A raw socket +/// is the only way to *decline* to share. +public final class DiscoveryPort: @unchecked Sendable { + /// The port held. + public let port: UInt16 - let listener = try makeListener(collector: collector) - defer { listener.cancel() } + private let fd: Int32 + private let lock = NSLock() + private var closed = false - let senders = targets.map { makeSender(to: $0, collector: collector) } - defer { senders.forEach { $0.cancel() } } + /// Take exclusive ownership of `port`. + /// + /// - Throws: ``DiscoverError/portUnavailable(port:)`` if another process + /// already holds it; ``DiscoverError/listenerFailed(_:)`` for any other + /// socket failure. + public init(port: UInt16 = Generated.port) throws { + self.port = port - let deadline = Date().addingTimeInterval(options.listenFor) - while Date() < deadline { - for sender in senders { - sender.send(content: Data(poll), completion: .contentProcessed { _ in }) + let handle = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) + guard handle >= 0 else { + throw DiscoverError.listenerFailed("socket(): \(DiscoveryPort.errnoText())") + } + + // Broadcast is required to reach 255.255.255.255 and the per-interface + // subnet broadcasts. Note what is *not* set: neither SO_REUSEADDR nor + // SO_REUSEPORT, so the bind below is exclusive. + var on: Int32 = 1 + guard + setsockopt( + handle, SOL_SOCKET, SO_BROADCAST, &on, socklen_t(MemoryLayout.size)) == 0 + else { + let text = DiscoveryPort.errnoText() + Darwin.close(handle) + throw DiscoverError.listenerFailed("SO_BROADCAST: \(text)") + } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr = in_addr(s_addr: in_addr_t(0)) // INADDR_ANY + let bound = withUnsafePointer(to: &address) { raw in + raw.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(handle, $0, socklen_t(MemoryLayout.size)) } - let wait = min(options.repeatEvery, max(deadline.timeIntervalSinceNow, 0)) - if wait <= 0 { break } - try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) - if Task.isCancelled { throw DiscoverError.cancelled } } - return collector.replies() + guard bound == 0 else { + let code = errno + let text = DiscoveryPort.errnoText(code) + Darwin.close(handle) + // The whole point of the exclusive bind: a port already in use is a + // conflict to report, not a condition to work around. + if code == EADDRINUSE { throw DiscoverError.portUnavailable(port: port) } + throw DiscoverError.listenerFailed("bind(): \(text)") + } + + self.fd = handle } - /// Convenience: discover for `listenFor` seconds and return the first device - /// found. - public static func findFirst(listenFor: TimeInterval = 3) async throws -> DiscoveryReply? { - var options = DiscoveryOptions() - options.listenFor = listenFor - return try await discover(options).first + deinit { close() } + + /// Release the port. Safe to call more than once. + public func close() { + lock.lock() + defer { lock.unlock() } + guard !closed else { return } + closed = true + Darwin.close(fd) } - // MARK: - Transport plumbing + /// Broadcast the poll and gather replies until the listen window ends. + /// + /// Returns one reply per distinct source address (last payload wins). The + /// device answers from a fresh ephemeral port each poll, so keying by full + /// source address would report one "device" per reply. + public func poll( + _ options: DiscoveryOptions = DiscoveryOptions() + ) async throws -> [DiscoveryReply] { + let collector = ReplyCollector() + let packet = KemperProtocol.buildPollRequest(mac: options.mac) + let targets = Discovery.broadcastTargets(extra: options.extraTargets) + let cancelled = CancelFlag() - private static func makeListener(collector: ReplyCollector) throws -> NWListener { - let parameters = NWParameters.udp - parameters.allowLocalEndpointReuse = true - guard let port = NWEndpoint.Port(rawValue: Generated.port) else { - throw DiscoverError.listenerFailed("invalid port") - } - let listener: NWListener - do { - listener = try NWListener(using: parameters, on: port) - } catch { - throw DiscoverError.listenerFailed(error.localizedDescription) - } - listener.newConnectionHandler = { connection in - connection.start(queue: discoveryQueue) - connection.receiveMessage { data, _, _, _ in - if let data, !data.isEmpty { - collector.record( - host: endpointHost(connection.endpoint), payload: [UInt8](data)) + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + DiscoveryPort.queue.async { + do { + try self.run( + packet: packet, targets: targets, options: options, + collector: collector, cancelled: cancelled) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } } - connection.cancel() } + } onCancel: { + cancelled.set() } - listener.start(queue: discoveryQueue) - return listener + + return collector.replies() } - private static func makeSender(to host: String, collector: ReplyCollector) -> NWConnection { - let parameters = NWParameters.udp - parameters.allowLocalEndpointReuse = true - // Poll from the shared port so a device that answers the source port is - // heard by the listener bound there. - if let localPort = NWEndpoint.Port(rawValue: Generated.port) { - parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.any), port: localPort) - } - let connection = NWConnection( - host: NWEndpoint.Host(host), - port: NWEndpoint.Port(rawValue: Generated.port) ?? .any, - using: parameters - ) - connection.start(queue: discoveryQueue) - receiveLoop(connection, collector: collector) - return connection - } - - /// Some stacks deliver the reply on the sending socket rather than the - /// listener; drain it either way. - private static func receiveLoop(_ connection: NWConnection, collector: ReplyCollector) { - connection.receiveMessage { data, _, _, error in - if let data, !data.isEmpty { - collector.record(host: endpointHost(connection.endpoint), payload: [UInt8](data)) + // MARK: - The blocking poll loop + + /// Runs on ``queue``: send a round, wait for readability, drain, repeat until + /// the window closes. Blocking here keeps it off the cooperative pool. + private func run( + packet: [UInt8], + targets: [String], + options: DiscoveryOptions, + collector: ReplyCollector, + cancelled: CancelFlag + ) throws { + let deadline = Date().addingTimeInterval(options.listenFor) + var nextPoll = Date.distantPast + var buffer = [UInt8](repeating: 0, count: 2048) + + while true { + if cancelled.isSet { throw DiscoverError.cancelled } + let now = Date() + if now >= deadline { break } + + if now >= nextPoll { + try sendRound(packet: packet, targets: targets) + nextPoll = now.addingTimeInterval(options.repeatEvery) } - guard error == nil else { return } - receiveLoop(connection, collector: collector) + + // Wake for whichever comes first: the next poll or the deadline. + let window = min(deadline, nextPoll).timeIntervalSince(Date()) + var descriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let ready = Darwin.poll(&descriptor, 1, Int32(max(0, window * 1000).rounded())) + if ready < 0 { + if errno == EINTR { continue } + throw DiscoverError.listenerFailed("poll(): \(DiscoveryPort.errnoText())") + } + if ready == 0 { continue } + + var from = sockaddr_in() + var fromLength = socklen_t(MemoryLayout.size) + let received = withUnsafeMutablePointer(to: &from) { raw in + raw.withMemoryRebound(to: sockaddr.self, capacity: 1) { sender in + buffer.withUnsafeMutableBytes { + recvfrom($0.baseAddress, $0.count, 0, sender, &fromLength, on: fd) + } + } + } + if received < 0 { + if errno == EINTR || errno == EAGAIN { continue } + throw DiscoverError.listenerFailed("recvfrom(): \(DiscoveryPort.errnoText())") + } + guard received > 0 else { continue } + + collector.record( + host: Discovery.ipv4String(from.sin_addr.s_addr), + payload: Array(buffer[0.. String { - switch endpoint { - case let .hostPort(host, _): - switch host { - case let .ipv4(address): - return "\(address)".components(separatedBy: "%").first ?? "\(address)" - case let .ipv6(address): - return "\(address)".components(separatedBy: "%").first ?? "\(address)" - case let .name(name, _): return name - @unknown default: return "\(host)" + /// Send the poll to every target. One unreachable target (a firewall denying + /// global broadcast, say) must not abort the sweep — only a total failure does. + private func sendRound(packet: [UInt8], targets: [String]) throws { + var sent = 0 + var lastError = "" + for target in targets { + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + guard inet_pton(AF_INET, target, &address.sin_addr) == 1 else { + lastError = "\(target): not an IPv4 address" + continue + } + let result = withUnsafePointer(to: &address) { raw in + raw.withMemoryRebound(to: sockaddr.self, capacity: 1) { destination in + packet.withUnsafeBytes { + sendto( + fd, $0.baseAddress, $0.count, 0, destination, + socklen_t(MemoryLayout.size)) + } + } + } + if result < 0 { + lastError = "\(target): \(DiscoveryPort.errnoText())" + } else { + sent += 1 } - default: - return "\(endpoint)" } + if sent == 0 && !lastError.isEmpty { throw DiscoverError.sendFailed(lastError) } + } + + private static let queue = DispatchQueue(label: "com.libkp.discovery") + + private static func errnoText(_ code: Int32 = errno) -> String { + String(cString: strerror(code)) + } +} + +/// `recvfrom` with the buffer arguments first, so the pointer dances above nest +/// in a readable order. +private func recvfrom( + _ buffer: UnsafeMutableRawPointer?, + _ count: Int, + _ flags: Int32, + _ sender: UnsafeMutablePointer, + _ senderLength: UnsafeMutablePointer, + on fd: Int32 +) -> Int { + Darwin.recvfrom(fd, buffer, count, flags, sender, senderLength) +} + +/// A cancellation bit shared between the task and the blocking loop. +private final class CancelFlag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + + func set() { + lock.lock() + flag = true + lock.unlock() + } + + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return flag + } +} + +/// UDP discovery: broadcast the poll, collect Profiler replies. +/// +/// Discovery and the TCP session share port 5727. The client broadcasts a fixed +/// poll packet; Profilers on the LAN reply on the same port. The exchange was +/// established by observed experimentation. +public enum Discovery { + /// Acquire the discovery port, poll once, and release it. + /// + /// A convenience for one-shot callers such as a CLI. Anything that goes on to + /// open a session should hold a ``DiscoveryPort`` across the session instead, + /// so no other process can take the port midway through. + public static func discover( + _ options: DiscoveryOptions = DiscoveryOptions() + ) async throws -> [DiscoveryReply] { + let port = try DiscoveryPort() + defer { port.close() } + return try await port.poll(options) + } + + /// Convenience: discover for `listenFor` seconds and return the first device + /// found. + public static func findFirst(listenFor: TimeInterval = 3) async throws -> DiscoveryReply? { + var options = DiscoveryOptions() + options.listenFor = listenFor + return try await discover(options).first } /// Broadcast targets: the global broadcast address, every local IPv4 @@ -210,7 +369,7 @@ public enum Discovery { } /// Format a network-byte-order IPv4 address. - private static func ipv4String(_ networkOrder: in_addr_t) -> String { + static func ipv4String(_ networkOrder: in_addr_t) -> String { let host = UInt32(bigEndian: networkOrder) return "\((host >> 24) & 0xFF).\((host >> 16) & 0xFF).\((host >> 8) & 0xFF).\(host & 0xFF)" } diff --git a/swift/Sources/LibKP/Errors.swift b/swift/Sources/LibKP/Errors.swift index 6be99c8..4b415f4 100644 --- a/swift/Sources/LibKP/Errors.swift +++ b/swift/Sources/LibKP/Errors.swift @@ -24,6 +24,18 @@ extension ParseError: CustomStringConvertible { public enum DiscoverError: Error, Sendable { /// The UDP listener could not be created or started. case listenerFailed(String) + /// The discovery port is already held by another process. + /// + /// LibKP takes UDP ``Generated/port`` exclusively for as long as a session is + /// active. The device answers a poll only on that port, and the kernel hands + /// each arriving reply to exactly one of the sockets bound to it — so a second + /// listener takes replies rather than seeing a copy. Binding exclusively + /// surfaces the clash here, at start-up, instead of as a device that + /// intermittently "cannot be found". + /// + /// The usual holder is other Kemper software on the same machine — Rig Manager + /// keeps the port open for its whole run — which must be quit first. + case portUnavailable(port: UInt16) /// No poll could be sent to any target. case sendFailed(String) /// The discovery run was cancelled before it finished. @@ -34,6 +46,12 @@ extension DiscoverError: CustomStringConvertible { public var description: String { switch self { case let .listenerFailed(detail): return "failed to open the discovery listener: \(detail)" + case let .portUnavailable(port): + return """ + UDP port \(port) is already held by another application. LibKP needs \ + exclusive use of it while a session is active; quit any other Kemper \ + software (Rig Manager keeps this port open) and try again. + """ case let .sendFailed(detail): return "failed to send the discovery poll: \(detail)" case .cancelled: return "discovery cancelled" } diff --git a/swift/Sources/MetersApp/DeviceStore.swift b/swift/Sources/MetersApp/DeviceStore.swift index 008d7ac..58e7e7c 100644 --- a/swift/Sources/MetersApp/DeviceStore.swift +++ b/swift/Sources/MetersApp/DeviceStore.swift @@ -175,6 +175,10 @@ final class DeviceStore: ObservableObject { private var model: DeviceModel? private var tasks: [Task] = [] private var epoch = 0 + /// The UDP discovery port, taken on first use and held for as long as this + /// store lives — deliberately not released between connect attempts. See + /// ``heldDiscoveryPort()``. + private var discoveryPort: DiscoveryPort? // The fast lane, accumulated off the published properties. private var frame = MeterFrame() @@ -282,8 +286,9 @@ final class DeviceStore: ObservableObject { } else { phase = .discovering do { - let reply = try await Discovery.findFirst( - listenFor: DeviceStore.discoveryWindow) + var options = DiscoveryOptions() + options.listenFor = DeviceStore.discoveryWindow + let reply = try await heldDiscoveryPort().poll(options).first guard epoch == self.epoch else { return } guard let reply else { phase = .failed(message: "No Profiler found on the network.") @@ -293,6 +298,15 @@ final class DeviceStore: ObservableObject { host = reply.host name = reply.name Log.conn("discovered \(reply.host) \(Log.opt(reply.name))") + } catch let error as DiscoverError { + guard epoch == self.epoch else { return } + // `DiscoverError` states the remedy itself — in particular + // ``DiscoverError/portUnavailable(port:)`` names the conflict + // rather than leaving it to look like an absent device. + Log.conn("discovery failed: \(error)") + phase = .failed(message: "\(error)") + await waitBeforeRetry() + continue } catch { guard epoch == self.epoch else { return } phase = .failed(message: "Discovery failed: \(error)") @@ -365,6 +379,26 @@ final class DeviceStore: ObservableObject { } } + /// The discovery port, acquired once and then held. + /// + /// LibKP requires sole ownership of UDP 5727: the device answers a poll only + /// on that port, and the kernel gives each reply to exactly one bound socket, + /// so a second listener on the machine takes replies instead of duplicating + /// them. Holding the port for the whole session means no other process can + /// take it between a dropped connection and the reconnect, and acquiring it + /// up front turns a conflict into a stated error rather than a device that + /// appears to be missing. + /// + /// Only ``ConnectionMode/automatic`` needs it. Manual mode connects straight + /// to a known address over TCP and never polls, so it does not claim the port. + private func heldDiscoveryPort() throws -> DiscoveryPort { + if let discoveryPort { return discoveryPort } + let port = try DiscoveryPort() + Log.conn("acquired UDP \(port.port) exclusively for this session") + discoveryPort = port + return port + } + /// Sleep out the retry delay. private func waitBeforeRetry() async { try? await Task.sleep(nanoseconds: UInt64(DeviceStore.retryDelay * 1_000_000_000)) diff --git a/swift/Tests/LibKPTests/UnitTests.swift b/swift/Tests/LibKPTests/UnitTests.swift index 2e94217..42c1c14 100644 --- a/swift/Tests/LibKPTests/UnitTests.swift +++ b/swift/Tests/LibKPTests/UnitTests.swift @@ -61,6 +61,66 @@ final class ProtocolTests: XCTestCase { } } +// MARK: - Exclusive ownership of the discovery port + +final class DiscoveryPortTests: XCTestCase { + /// A second acquire must fail rather than quietly share the port. Sharing is + /// the failure this guards against: the kernel gives an arriving reply to + /// exactly one bound socket, so a co-bound listener steals replies instead of + /// duplicating them. + func testThePortIsHeldExclusively() throws { + let first = try DiscoveryPort(port: 54331) + defer { first.close() } + XCTAssertEqual(first.port, 54331) + + XCTAssertThrowsError(try DiscoveryPort(port: 54331)) { error in + guard case let DiscoverError.portUnavailable(port) = error else { + return XCTFail("expected portUnavailable, got \(error)") + } + XCTAssertEqual(port, 54331) + XCTAssertTrue("\(error)".contains("exclusive")) + } + } + + func testReleasingThePortLetsItBeAcquiredAgain() throws { + try DiscoveryPort(port: 54332).close() + try DiscoveryPort(port: 54332).close() // no leak: the first release freed it + } + + func testClosingAPortTwiceIsHarmless() throws { + let port = try DiscoveryPort(port: 54333) + port.close() + port.close() + } + + /// A long-running client re-polls to notice devices coming and going, without + /// ever releasing the port in between. + func testAHeldPortCanBePolledRepeatedly() async throws { + let port = try DiscoveryPort(port: 54334) + defer { port.close() } + var options = DiscoveryOptions() + options.listenFor = 0.1 + options.repeatEvery = 0.05 + options.extraTargets = ["127.0.0.1"] + + // Our own echoed poll must not be mistaken for a device. + for _ in 0..<2 { + let replies = try await port.poll(options) + XCTAssertTrue(replies.isEmpty) + } + } + + /// The one-shot helper must not leave the port held behind it. + func testDiscoverReleasesThePortItAcquired() async throws { + var options = DiscoveryOptions() + options.listenFor = 0.1 + options.repeatEvery = 0.05 + _ = try await Discovery.discover(options) + // Would throw if `discover` leaked the standard port. + try DiscoveryPort().close() + } +} + // MARK: - MIDI3 framing final class Midi3Tests: XCTestCase {