Skip to main content

midi_controller/
property_exchange.rs

1//! Minimal MIDI-CI Property Exchange (PE) framing.
2//!
3//! Handles Set Property Inquiry (sub-ID2 = 0x36) and replies with ACK (0x37).
4//! Spec reference: MIDI-CI 1.2, §7 Property Exchange.
5//!
6//! SysEx7 message layout:
7//!   F0 7E <device_id> 0D <sub_id2> <ci_version>
8//!   <source_muid: 4 bytes> <dest_muid: 4 bytes>
9//!   <request_id>
10//!   <header_len_lo> <header_len_hi>
11//!   <header_data...>
12//!   <num_chunks_lo> <num_chunks_hi>
13//!   <chunk_num_lo> <chunk_num_hi>
14//!   <body_len_lo> <body_len_hi>
15//!   <body_data...>
16//!   F7
17//!
18//! Reply header format: `[status]` (1 byte for Set Reply) or `[status, resource]` (2 bytes for Get Reply).
19
20use heapless::Vec;
21
22const UNIVERSAL_SYSEX: u8 = 0x7E;
23const SUB_ID1_MIDI_CI: u8 = 0x0D;
24
25/// Sub-ID2: Inquiry Get Property Data (MIDI-CI 1.2)
26pub const PE_GET_INQUIRY: u8 = 0x34;
27/// Sub-ID2: Reply to Get Property Data
28pub const PE_GET_REPLY: u8 = 0x35;
29/// Sub-ID2: Inquiry Set Property Data (MIDI-CI 1.2)
30pub const PE_SET_INQUIRY: u8 = 0x36;
31/// Sub-ID2: Reply to Set Property Data
32pub const PE_SET_REPLY: u8 = 0x37;
33
34/// PE reply status codes (HTTP-inspired).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[repr(u8)]
37pub enum PeStatus {
38    /// 200 OK — request succeeded.
39    Ok = 0x00,
40    /// 404 Not Found — resource (preset index) does not exist.
41    NotFound = 0x01,
42    /// 422 Unprocessable — body could not be deserialized.
43    FormatError = 0x02,
44    /// 409 Conflict — flash format version mismatch, re-upload required.
45    VersionMismatch = 0x03,
46}
47
48impl PeStatus {
49    pub fn from_byte(b: u8) -> Option<Self> {
50        match b {
51            0x00 => Some(Self::Ok),
52            0x01 => Some(Self::NotFound),
53            0x02 => Some(Self::FormatError),
54            0x03 => Some(Self::VersionMismatch),
55            _ => None,
56        }
57    }
58
59    pub fn is_ok(self) -> bool {
60        self == Self::Ok
61    }
62}
63
64const CI_VERSION: u8 = 0x02;
65
66/// Check if a SysEx buffer is a MIDI-CI message.
67pub fn is_ci_message(buf: &[u8]) -> bool {
68    buf.len() >= 15 && buf[0] == 0xF0 && buf[1] == UNIVERSAL_SYSEX && buf[3] == SUB_ID1_MIDI_CI
69}
70
71/// Check if the message is a Set Property Inquiry.
72pub fn is_set_property(buf: &[u8]) -> bool {
73    is_ci_message(buf) && buf[4] == PE_SET_INQUIRY
74}
75
76/// Extract the request_id field.
77pub fn request_id(buf: &[u8]) -> u8 {
78    buf[14]
79}
80
81/// Extract the source MUID (4 bytes).
82pub fn source_muid(buf: &[u8]) -> [u8; 4] {
83    [buf[6], buf[7], buf[8], buf[9]]
84}
85
86/// Parsed Set Property Inquiry with resource identifier and body.
87pub struct SetPropertyData<'a> {
88    /// Resource identifier (preset index) from header.
89    pub resource: u8,
90    /// Body payload.
91    pub body: &'a [u8],
92}
93
94/// Extract resource identifier and body from a Set Property Inquiry.
95pub fn extract_set_property(buf: &[u8]) -> Option<SetPropertyData<'_>> {
96    if !is_set_property(buf) {
97        return None;
98    }
99    if buf.len() < 16 {
100        return None;
101    }
102
103    let mut pos = 15;
104
105    // header_len (2 bytes, 7-bit LSB encoding)
106    if pos + 2 > buf.len() {
107        return None;
108    }
109    let header_len = (buf[pos] as usize) | ((buf[pos + 1] as usize) << 7);
110    pos += 2;
111
112    // resource = first header byte (preset index), or 0 if no header
113    let resource = if header_len > 0 && pos < buf.len() {
114        buf[pos]
115    } else {
116        0
117    };
118    pos += header_len;
119
120    // num_chunks + chunk_num (4 bytes)
121    if pos + 4 > buf.len() {
122        return None;
123    }
124    pos += 4;
125
126    // body_len (2 bytes, 7-bit LSB encoding)
127    if pos + 2 > buf.len() {
128        return None;
129    }
130    let body_len = (buf[pos] as usize) | ((buf[pos + 1] as usize) << 7);
131    pos += 2;
132
133    if pos + body_len > buf.len() {
134        return None;
135    }
136    Some(SetPropertyData {
137        resource,
138        body: &buf[pos..pos + body_len],
139    })
140}
141
142/// Legacy helper: extract just the body (ignores resource).
143pub fn extract_body(buf: &[u8]) -> Option<&[u8]> {
144    extract_set_property(buf).map(|d| d.body)
145}
146
147/// Build a Set Property Reply with status.
148pub fn build_set_reply(
149    device_muid: [u8; 4],
150    dest_muid: [u8; 4],
151    req_id: u8,
152    status: PeStatus,
153) -> Vec<u8, 32> {
154    let mut msg: Vec<u8, 32> = Vec::new();
155    let _ = msg.push(0xF0);
156    let _ = msg.push(UNIVERSAL_SYSEX);
157    let _ = msg.push(0x7F); // device_id: function block
158    let _ = msg.push(SUB_ID1_MIDI_CI);
159    let _ = msg.push(PE_SET_REPLY);
160    let _ = msg.push(CI_VERSION);
161    for &b in &device_muid {
162        let _ = msg.push(b);
163    }
164    for &b in &dest_muid {
165        let _ = msg.push(b);
166    }
167    let _ = msg.push(req_id);
168    // header_len = 1 (status byte)
169    let _ = msg.push(0x01);
170    let _ = msg.push(0x00);
171    let _ = msg.push(status as u8);
172    // num_chunks = 1, chunk_num = 1
173    let _ = msg.push(0x01);
174    let _ = msg.push(0x00);
175    let _ = msg.push(0x01);
176    let _ = msg.push(0x00);
177    // body_len = 0
178    let _ = msg.push(0x00);
179    let _ = msg.push(0x00);
180    let _ = msg.push(0xF7);
181    msg
182}
183
184/// Build a Set Property Inquiry message (CLI → device).
185/// `resource` is the preset index (carried in header).
186/// `body` must contain only 7-bit safe bytes.
187pub fn build_set_inquiry(
188    source_muid: [u8; 4],
189    dest_muid: [u8; 4],
190    req_id: u8,
191    resource: u8,
192    body: &[u8],
193) -> Vec<u8, 350> {
194    let mut msg: Vec<u8, 350> = Vec::new();
195    let _ = msg.push(0xF0);
196    let _ = msg.push(UNIVERSAL_SYSEX);
197    let _ = msg.push(0x7F);
198    let _ = msg.push(SUB_ID1_MIDI_CI);
199    let _ = msg.push(PE_SET_INQUIRY);
200    let _ = msg.push(CI_VERSION);
201    for &b in &source_muid {
202        let _ = msg.push(b);
203    }
204    for &b in &dest_muid {
205        let _ = msg.push(b);
206    }
207    let _ = msg.push(req_id);
208    // header_len = 1 (resource byte)
209    let _ = msg.push(0x01);
210    let _ = msg.push(0x00);
211    let _ = msg.push(resource);
212    // num_chunks = 1, chunk_num = 1
213    let _ = msg.push(0x01);
214    let _ = msg.push(0x00);
215    let _ = msg.push(0x01);
216    let _ = msg.push(0x00);
217    // body (mcoded7 encoded)
218    let mut encoded_body = [0u8; 300];
219    let enc_len = encode_mcoded7(body, &mut encoded_body);
220    let _ = msg.push((enc_len & 0x7F) as u8);
221    let _ = msg.push(((enc_len >> 7) & 0x7F) as u8);
222    for &b in &encoded_body[..enc_len] {
223        let _ = msg.push(b);
224    }
225    let _ = msg.push(0xF7);
226    msg
227}
228
229/// Check if the message is a Get Property Inquiry.
230pub fn is_get_property(buf: &[u8]) -> bool {
231    is_ci_message(buf) && buf[4] == PE_GET_INQUIRY
232}
233
234/// Check if the message is a Get Property Reply.
235pub fn is_get_reply(buf: &[u8]) -> bool {
236    is_ci_message(buf) && buf[4] == PE_GET_REPLY
237}
238
239/// Extract status from a Set Property Reply or Get Property Reply.
240/// Status is the first header byte in replies.
241pub fn extract_reply_status(buf: &[u8]) -> Option<PeStatus> {
242    if buf.len() < 16 || !is_ci_message(buf) {
243        return None;
244    }
245    let sub_id2 = buf[4];
246    if sub_id2 != PE_SET_REPLY && sub_id2 != PE_GET_REPLY {
247        return None;
248    }
249    let pos = 15;
250    if pos + 2 > buf.len() {
251        return None;
252    }
253    let header_len = (buf[pos] as usize) | ((buf[pos + 1] as usize) << 7);
254    if header_len == 0 {
255        // Legacy reply without status — treat as Ok
256        return Some(PeStatus::Ok);
257    }
258    if pos + 2 + 1 > buf.len() {
259        return None;
260    }
261    PeStatus::from_byte(buf[pos + 2])
262}
263
264/// Extract body from a Get Property Reply.
265pub fn extract_get_body(buf: &[u8]) -> Option<&[u8]> {
266    if !is_get_reply(buf) || buf.len() < 16 {
267        return None;
268    }
269    let mut pos = 15;
270    if pos + 2 > buf.len() {
271        return None;
272    }
273    let header_len = (buf[pos] as usize) | ((buf[pos + 1] as usize) << 7);
274    pos += 2 + header_len;
275    // num_chunks + chunk_num
276    if pos + 4 > buf.len() {
277        return None;
278    }
279    pos += 4;
280    // body_len
281    if pos + 2 > buf.len() {
282        return None;
283    }
284    let body_len = (buf[pos] as usize) | ((buf[pos + 1] as usize) << 7);
285    pos += 2;
286    if pos + body_len > buf.len() {
287        return None;
288    }
289    Some(&buf[pos..pos + body_len])
290}
291
292/// Extract the resource identifier from a Get Property Inquiry.
293pub fn extract_get_resource(buf: &[u8]) -> Option<u8> {
294    if !is_get_property(buf) || buf.len() < 16 {
295        return None;
296    }
297    let pos = 15;
298    if pos + 2 > buf.len() {
299        return None;
300    }
301    let header_len = (buf[pos] as usize) | ((buf[pos + 1] as usize) << 7);
302    if header_len > 0 && pos + 2 < buf.len() {
303        Some(buf[pos + 2])
304    } else {
305        Some(0)
306    }
307}
308
309/// Build a Get Property Inquiry message (CLI → device).
310pub fn build_get_inquiry(
311    source_muid: [u8; 4],
312    dest_muid: [u8; 4],
313    req_id: u8,
314    resource: u8,
315) -> Vec<u8, 32> {
316    let mut msg: Vec<u8, 32> = Vec::new();
317    let _ = msg.push(0xF0);
318    let _ = msg.push(UNIVERSAL_SYSEX);
319    let _ = msg.push(0x7F);
320    let _ = msg.push(SUB_ID1_MIDI_CI);
321    let _ = msg.push(PE_GET_INQUIRY);
322    let _ = msg.push(CI_VERSION);
323    for &b in &source_muid {
324        let _ = msg.push(b);
325    }
326    for &b in &dest_muid {
327        let _ = msg.push(b);
328    }
329    let _ = msg.push(req_id);
330    // header_len = 1 (resource byte)
331    let _ = msg.push(0x01);
332    let _ = msg.push(0x00);
333    let _ = msg.push(resource);
334    // num_chunks = 1, chunk_num = 1
335    let _ = msg.push(0x01);
336    let _ = msg.push(0x00);
337    let _ = msg.push(0x01);
338    let _ = msg.push(0x00);
339    // body_len = 0
340    let _ = msg.push(0x00);
341    let _ = msg.push(0x00);
342    let _ = msg.push(0xF7);
343    msg
344}
345
346/// Build a Get Property Reply with status and body data.
347pub fn build_get_reply(
348    device_muid: [u8; 4],
349    dest_muid: [u8; 4],
350    req_id: u8,
351    resource: u8,
352    status: PeStatus,
353    body: &[u8],
354) -> Vec<u8, 350> {
355    let mut msg: Vec<u8, 350> = Vec::new();
356    let _ = msg.push(0xF0);
357    let _ = msg.push(UNIVERSAL_SYSEX);
358    let _ = msg.push(0x7F);
359    let _ = msg.push(SUB_ID1_MIDI_CI);
360    let _ = msg.push(PE_GET_REPLY);
361    let _ = msg.push(CI_VERSION);
362    for &b in &device_muid {
363        let _ = msg.push(b);
364    }
365    for &b in &dest_muid {
366        let _ = msg.push(b);
367    }
368    let _ = msg.push(req_id);
369    // header_len = 2 (status + resource)
370    let _ = msg.push(0x02);
371    let _ = msg.push(0x00);
372    let _ = msg.push(status as u8);
373    let _ = msg.push(resource);
374    // num_chunks = 1, chunk_num = 1
375    let _ = msg.push(0x01);
376    let _ = msg.push(0x00);
377    let _ = msg.push(0x01);
378    let _ = msg.push(0x00);
379    // body (mcoded7 encoded)
380    let mut encoded_body = [0u8; 300];
381    let enc_len = encode_mcoded7(body, &mut encoded_body);
382    let _ = msg.push((enc_len & 0x7F) as u8);
383    let _ = msg.push(((enc_len >> 7) & 0x7F) as u8);
384    for &b in &encoded_body[..enc_len] {
385        let _ = msg.push(b);
386    }
387    let _ = msg.push(0xF7);
388    msg
389}
390
391/// Encode data using mcoded7 (MIDI-CI spec): every 7 input bytes → 8 output bytes.
392/// The first byte of each group carries the high bits (bit 7) of the following 7 bytes.
393/// Returns the number of bytes written to `out`.
394pub fn encode_mcoded7(data: &[u8], out: &mut [u8]) -> usize {
395    let mut pos = 0;
396    for chunk in data.chunks(7) {
397        let mut high_bits: u8 = 0;
398        for (i, &b) in chunk.iter().enumerate() {
399            if b & 0x80 != 0 {
400                high_bits |= 1 << i;
401            }
402        }
403        out[pos] = high_bits;
404        pos += 1;
405        for &b in chunk {
406            out[pos] = b & 0x7F;
407            pos += 1;
408        }
409    }
410    pos
411}
412
413/// Decode mcoded7 data back to original bytes.
414/// Returns the number of bytes written to `out`.
415pub fn decode_mcoded7(data: &[u8], out: &mut [u8]) -> usize {
416    let mut pos = 0;
417    let mut i = 0;
418    while i < data.len() {
419        let high_bits = data[i];
420        i += 1;
421        for bit in 0..7 {
422            if i >= data.len() {
423                break;
424            }
425            out[pos] = data[i] | (if high_bits & (1 << bit) != 0 { 0x80 } else { 0 });
426            pos += 1;
427            i += 1;
428        }
429    }
430    pos
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn roundtrip_set_property() {
439        let payload = b"hello";
440        let msg = build_set_inquiry(
441            [0x10, 0x20, 0x30, 0x40],
442            [0x01, 0x02, 0x03, 0x04],
443            0x07,
444            3,
445            payload,
446        );
447
448        assert!(is_ci_message(&msg));
449        assert!(is_set_property(&msg));
450        assert_eq!(request_id(&msg), 0x07);
451        assert_eq!(source_muid(&msg), [0x10, 0x20, 0x30, 0x40]);
452
453        let data = extract_set_property(&msg).unwrap();
454        assert_eq!(data.resource, 3);
455        let mut decoded = [0u8; 32];
456        let dec_len = decode_mcoded7(data.body, &mut decoded);
457        assert_eq!(&decoded[..dec_len], b"hello");
458    }
459
460    #[test]
461    fn not_ci_for_opendeck() {
462        let buf = [0xF0, 0x00, 0x53, 0x43, 0x00, 0x00, 0x01, 0xF7];
463        assert!(!is_ci_message(&buf));
464    }
465
466    #[test]
467    fn reply_structure() {
468        let reply = build_set_reply(
469            [0x01, 0x02, 0x03, 0x04],
470            [0x10, 0x20, 0x30, 0x40],
471            0x07,
472            PeStatus::Ok,
473        );
474        assert_eq!(reply[0], 0xF0);
475        assert_eq!(reply[4], PE_SET_REPLY);
476        assert_eq!(reply[14], 0x07);
477        // header_len = 1, status = 0x00 (Ok)
478        assert_eq!(reply[15], 0x01); // header_len_lo
479        assert_eq!(reply[17], 0x00); // status byte
480        assert_eq!(*reply.last().unwrap(), 0xF7);
481        assert_eq!(extract_reply_status(&reply), Some(PeStatus::Ok));
482    }
483
484    #[test]
485    fn reply_status_not_found() {
486        let reply = build_set_reply(
487            [0x01, 0x02, 0x03, 0x04],
488            [0x10, 0x20, 0x30, 0x40],
489            0x01,
490            PeStatus::NotFound,
491        );
492        assert_eq!(extract_reply_status(&reply), Some(PeStatus::NotFound));
493    }
494
495    #[test]
496    fn get_reply_status_extracted() {
497        let reply = build_get_reply(
498            [0x01, 0x02, 0x03, 0x04],
499            [0x10, 0x20, 0x30, 0x40],
500            0x01,
501            5,
502            PeStatus::NotFound,
503            &[],
504        );
505        assert_eq!(extract_reply_status(&reply), Some(PeStatus::NotFound));
506    }
507
508    #[test]
509    fn extract_body_with_header() {
510        let mut msg: Vec<u8, 128> = Vec::new();
511        let _ = msg.push(0xF0);
512        let _ = msg.push(UNIVERSAL_SYSEX);
513        let _ = msg.push(0x7F);
514        let _ = msg.push(SUB_ID1_MIDI_CI);
515        let _ = msg.push(PE_SET_INQUIRY);
516        let _ = msg.push(CI_VERSION);
517        for &b in &[0x10, 0x20, 0x30, 0x40, 0x01, 0x02, 0x03, 0x04] {
518            let _ = msg.push(b);
519        }
520        let _ = msg.push(0x01); // request_id
521                                // header_len = 3
522        let _ = msg.push(0x03);
523        let _ = msg.push(0x00);
524        for &b in b"abc" {
525            let _ = msg.push(b);
526        }
527        // chunks
528        let _ = msg.push(0x01);
529        let _ = msg.push(0x00);
530        let _ = msg.push(0x01);
531        let _ = msg.push(0x00);
532        // body_len = 4
533        let _ = msg.push(0x04);
534        let _ = msg.push(0x00);
535        for &b in b"data" {
536            let _ = msg.push(b);
537        }
538        let _ = msg.push(0xF7);
539
540        assert_eq!(extract_body(&msg).unwrap(), b"data");
541    }
542
543    #[test]
544    fn get_property_roundtrip() {
545        let inquiry =
546            build_get_inquiry([0x10, 0x20, 0x30, 0x40], [0x01, 0x02, 0x03, 0x04], 0x05, 7);
547        assert!(is_get_property(&inquiry));
548        assert!(!is_set_property(&inquiry));
549        assert_eq!(extract_get_resource(&inquiry), Some(7));
550        assert_eq!(request_id(&inquiry), 0x05);
551    }
552
553    #[test]
554    fn get_reply_body_extraction() {
555        let body = b"preset data here";
556        let reply = build_get_reply(
557            [0x01, 0x02, 0x03, 0x04],
558            [0x10, 0x20, 0x30, 0x40],
559            0x03,
560            2,
561            PeStatus::Ok,
562            body,
563        );
564        assert!(is_get_reply(&reply));
565        let raw = extract_get_body(&reply).unwrap();
566        let mut decoded = [0u8; 32];
567        let dec_len = decode_mcoded7(raw, &mut decoded);
568        assert_eq!(&decoded[..dec_len], body);
569    }
570
571    #[test]
572    fn mcoded7_roundtrip_short() {
573        let data = [0x80, 0xFF, 0x00, 0x7F];
574        let mut encoded = [0u8; 256];
575        let enc_len = encode_mcoded7(&data, &mut encoded);
576        let mut decoded = [0u8; 256];
577        let dec_len = decode_mcoded7(&encoded[..enc_len], &mut decoded);
578        assert_eq!(&decoded[..dec_len], &data);
579    }
580
581    #[test]
582    fn mcoded7_roundtrip_exact_7() {
583        let data = [0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86];
584        let mut encoded = [0u8; 256];
585        let enc_len = encode_mcoded7(&data, &mut encoded);
586        assert_eq!(enc_len, 8); // 7 input → 8 output
587        let mut decoded = [0u8; 256];
588        let dec_len = decode_mcoded7(&encoded[..enc_len], &mut decoded);
589        assert_eq!(&decoded[..dec_len], &data);
590    }
591
592    #[test]
593    fn mcoded7_roundtrip_14_bytes() {
594        let data = [0xFF; 14];
595        let mut encoded = [0u8; 256];
596        let enc_len = encode_mcoded7(&data, &mut encoded);
597        assert_eq!(enc_len, 16); // 14 → 2 groups of 8
598        let mut decoded = [0u8; 256];
599        let dec_len = decode_mcoded7(&encoded[..enc_len], &mut decoded);
600        assert_eq!(&decoded[..dec_len], &data);
601    }
602
603    #[test]
604    fn mcoded7_all_7bit_safe_is_passthrough_with_prefix() {
605        let data = [0x01, 0x7F, 0x00, 0x55];
606        let mut encoded = [0u8; 256];
607        let enc_len = encode_mcoded7(&data, &mut encoded);
608        // High bits byte should be 0 (no high bits set)
609        assert_eq!(encoded[0], 0x00);
610        assert_eq!(&encoded[1..5], &data);
611        assert_eq!(enc_len, 5);
612    }
613
614    #[test]
615    fn mcoded7_custom_rgb_color() {
616        // Simulate a postcard-serialized Custom(255, 128, 0)
617        let data = [0xFF, 0x80, 0x00];
618        let mut encoded = [0u8; 256];
619        let enc_len = encode_mcoded7(&data, &mut encoded);
620        let mut decoded = [0u8; 256];
621        let dec_len = decode_mcoded7(&encoded[..enc_len], &mut decoded);
622        assert_eq!(&decoded[..dec_len], &data);
623    }
624}