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/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/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/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/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/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/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/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..11e571a 100644 --- a/python/README.md +++ b/python/README.md @@ -122,6 +122,20 @@ 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 | @@ -130,6 +144,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 +178,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..d3f2266 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, @@ -42,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, @@ -52,6 +59,7 @@ async def main(): FieldOverrunError, LibKPError, ParseError, + PortUnavailableError, ProtocolRejectedError, SessionError, TimeoutErrorLibKP, @@ -82,6 +90,7 @@ async def main(): EFFECT_SLOTS, describe, describe_numeric, + effect_category_name, effect_slot_page, effect_type_name, page_name, @@ -91,6 +100,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 +110,14 @@ async def main(): from .state import ( Amp, ApplyOutcome, + Bank, + BankPreview, + BankSlot, BeatPulse, Cabinet, Connected, Connection, + CurrentPosition, DeviceEvent, DeviceState, Disconnected, @@ -135,11 +150,14 @@ async def main(): "DiscoveryOptions", "Reply", "discover", + "DiscoveryPort", "find_first", "Session", "HandshakeOutcome", "PROTOCOL_MIDI3_STREAM", "PROTOCOL_REQUEST_RESPONSE", + "PROTOCOL_CBOR_CONTROL", + "CONNECTION_COOLDOWN", "Unframer", "frame", "is_kemper_sysex", @@ -172,6 +190,7 @@ async def main(): "page_name", "string_tag_name", "effect_type_name", + "effect_category_name", "effect_slot_page", "describe", "describe_numeric", @@ -189,10 +208,13 @@ async def main(): "Effect", "Tuner", "Output", + "BankSlot", + "Bank", "ApplyOutcome", "DeviceEvent", "RigChanged", "StringTag", + "BankPreview", "EffectChanged", "ParamChanged", "Status", @@ -202,12 +224,20 @@ 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", + "PortUnavailableError", "TooShortError", "FieldOverrunError", "DiscoverError", 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/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/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/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/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/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/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_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/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_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/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/README.md b/rust/README.md index 158caf7..30509cd 100644 --- a/rust/README.md +++ b/rust/README.md @@ -36,15 +36,28 @@ while let Ok(state) = snapshots.recv().await { # } ``` +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 | |---|---| | `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 +83,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/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/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/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/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/lib.rs b/rust/src/lib.rs index eefe5e3..298af77 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,8 +58,9 @@ 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 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}; @@ -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/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/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/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/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/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/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/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/README.md b/swift/README.md index 8ff9869..78f52fd 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 ``` @@ -20,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 | @@ -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 @@ -79,6 +81,19 @@ if let device = try await Discovery.findFirst(listenFor: 3) { } ``` +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` @@ -89,9 +104,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 +139,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 +173,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. 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/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/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/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/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() 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/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..58e7e7c --- /dev/null +++ b/swift/Sources/MetersApp/DeviceStore.swift @@ -0,0 +1,676 @@ +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 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() + 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 { + 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.") + await waitBeforeRetry() + continue + } + 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)") + 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() + } + } + } + + /// 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)) + } + + /// 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() + } + ) + } +} 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..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 { @@ -233,6 +293,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 +363,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 +379,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 +460,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 +628,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 +1005,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) + } +}