nord-usb 0.1.0

Clavia / Nord device transport and vendor protocol over USB
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
//! Typed operations.
//!
//! Each is a single-item primitive that runs inside a [`Session`]; a caller batches by
//! opening one session and looping (which is exactly how NSM batches — the wrapper once,
//! the per-item unit repeated).
//!
//! # What is reproduced, and what is not
//!
//! These emit the command bytes NSM sends to *effect* an operation, including the
//! fire-and-forget progress strings that paint the instrument's own display
//! ([`ui::label`]/[`ui::percent`]). They deliberately omit the reads NSM issues purely
//! to repaint its **host-side browser** — the `INFO`/`DEPENDENCIES` refresh after a
//! copy, the `STATUS` counter re-read that closes each transaction, and the whole
//! bank-refresh transaction that follows a write. Those change nothing on the device;
//! reproducing a specific GUI's bookkeeping is not the library's job. Everything sent
//! here is verified byte-for-byte against the capture corpus.

use crate::envelope;
use crate::error::{Error, Result};
use crate::session::ReadWrite;
use crate::session::Session;
use crate::transport::Transport;
use crate::wire::{
    cmd, ui, Bank, Dependency, Location, Message, ObjectClass, Partition, ProgramInfo, Service,
    Status,
};

/// Query the inventory for the class the session was opened with.
///
/// **Read-only.** It sends one request and reads counters back; nothing on the
/// instrument changes. That makes it the safe way to prove the whole stack works
/// against real hardware.
pub async fn status<T: Transport, C>(session: &mut Session<'_, T, C>) -> Result<Status> {
    let class = session.class();
    let resp = session
        .request(
            Service::Program,
            10,
            cmd::STATUS,
            &class.to_raw().to_be_bytes(),
        )
        .await?;
    Status::decode(class, &resp)
}

/// Query every class worth reporting, one transaction each.
///
/// Each class needs its own session because the class is fixed at `SESSION_OPEN`.
/// A class that errors is skipped rather than failing the sweep — instruments differ
/// in which classes they answer for.
pub async fn inventory<T: Transport>(transport: &mut T) -> Result<Vec<Status>> {
    let mut out = Vec::new();
    for class in ObjectClass::INVENTORY {
        let mut session = match Session::open(transport, class).await {
            Ok(s) => s,
            Err(_) => continue,
        };
        match status(&mut session).await {
            Ok(s) => {
                session.commit().await?;
                out.push(s);
            }
            // The class is skipped, but the transaction still gets its closing
            // exchanges — an abandoned session strands the instrument on its progress
            // screen. A close that fails too is genuinely unrecoverable here.
            Err(_) => {
                let _ = session.commit().await;
            }
        }
    }
    Ok(out)
}

/// Ask the device about one slot: format tag, body length, name, body checksum.
///
/// **Read-only.**
pub async fn info<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    at: Location,
) -> Result<ProgramInfo> {
    let mut args = Vec::new();
    at.write_to(&mut args);
    let resp = session
        .request(Service::Program, 10, cmd::INFO, &args)
        .await?;
    ProgramInfo::decode(&resp)
}

/// Read one program off the instrument, returning the bytes of a `.ne5p` file.
///
/// **Read-only.** The body is wrapped in a `CBIN` header ([`envelope`]) so the result
/// is a real file, and the device's own CRC-32 is checked against it when the device
/// supplies one.
pub async fn read_program<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    at: Location,
) -> Result<Vec<u8>> {
    let (meta, body) = transfer_out(session, at).await?;

    let file = envelope::wrap(&meta.format, at, meta.version, &body)?;
    if let Some(expected) = meta.crc32 {
        let actual = envelope::crc32(&body);
        if expected != actual {
            return Err(Error::Envelope(format!(
                "body checksum mismatch: device reported {expected:08x}, received {actual:08x}"
            )));
        }
    }
    Ok(file)
}

/// Read an entity's body off the instrument **without** wrapping it in a CBIN header.
///
/// For formats whose header layout is not yet known — notably CBIN **type-0**, the
/// legacy no-CRC variant — wrapping would fabricate a header rather than reproduce one.
/// This returns exactly the bytes the device sent, which is the safe thing to archive.
pub async fn read_body<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    at: Location,
) -> Result<Vec<u8>> {
    Ok(transfer_out(session, at).await?.1)
}

/// Body bytes to ask for in one `READ`. A body larger than this arrives across several
/// requests with the offset advancing by exactly this much and a short final chunk.
///
/// Confirmed from captures: NSM asks for `32720`. Some objects are instead read at
/// `32726` throughout — a fixed 6-byte difference that is per object, not per chunk, and
/// unexplained. Both fit inside one `READ_BUFFER`, and the host chooses the number, so
/// the smaller is used uniformly.
const READ_CHUNK: u32 = 32720;

/// Body bytes per `WRITE_DATA` frame. The whole frame must stay under the device's
/// max transfer; an oversized frame wedges the instrument until a power cycle.
const WRITE_CHUNK: usize = 32720;

/// Probe overrides for the transfer chunk sizes; the defaults are the captured values.
#[cfg(feature = "fault-injection")]
fn read_chunk() -> u32 {
    std::env::var("NORD_READ_CHUNK")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(READ_CHUNK)
}
#[cfg(not(feature = "fault-injection"))]
fn read_chunk() -> u32 {
    READ_CHUNK
}

#[cfg(feature = "fault-injection")]
fn write_chunk() -> usize {
    std::env::var("NORD_WRITE_CHUNK")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(WRITE_CHUNK)
}
#[cfg(not(feature = "fault-injection"))]
fn write_chunk() -> usize {
    WRITE_CHUNK
}

/// Bytes per storage block in the library partitions (piano, sample) — the unit
/// `STATUS`'s free/used words count there.
const LIBRARY_BLOCK: usize = 262_144;

/// The shared read sequence NSM uses, reproduced byte-for-byte: `INFO` to learn the
/// body length, the `"Uploading..."` progress label the instrument paints, `BEGIN_READ`,
/// one `READ` per [`READ_CHUNK`] with the bar advancing as they arrive, then
/// `END_TRANSFER`. Returns the metadata and the reassembled body.
///
/// ("Uploading" is NSM's own — and backwards — word for keyboard → host.)
async fn transfer_out<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    at: Location,
) -> Result<(ProgramInfo, Vec<u8>)> {
    let meta = info(session, at).await?;

    session.notify(&ui::label("Uploading...")?).await?;

    let mut args = Vec::new();
    at.write_to(&mut args);
    session
        .request(Service::Program, 10, cmd::BEGIN_READ, &args)
        .await?;

    // Capacity is clamped: `body_len` is device-supplied, and a corrupt or hostile
    // value must not become a gigabyte allocation up front. Real bodies larger than the
    // clamp (pianos) just grow the vector as chunks arrive.
    let mut body = Vec::with_capacity((meta.body_len as usize).min(1 << 20));
    let mut painted = None;
    while (body.len() as u32) < meta.body_len {
        let offset = body.len() as u32;
        let want = read_chunk().min(meta.body_len - offset);

        let mut req = args.clone();
        req.extend_from_slice(&offset.to_be_bytes());
        req.extend_from_slice(&want.to_be_bytes());
        let resp = session
            .request(Service::Program, 10, cmd::READ, &req)
            .await?;

        // Payload is bank, slot, offset, length, then this chunk of the body.
        let p = resp.payload();
        let chunk = p.get(16..).ok_or(Error::Truncated {
            got: p.len(),
            need: 16,
        })?;
        // A short chunk would silently misalign every subsequent offset, so it is an
        // error rather than something to resynchronize from.
        if chunk.len() != want as usize {
            return Err(Error::Transport(format!(
                "asked for {want} bytes at offset {offset} but the device sent {}",
                chunk.len()
            )));
        }
        body.extend_from_slice(chunk);

        // The bar is bytes transferred over bytes expected, and only moves on a whole
        // percent — one message per step, the way NSM drives it. A body inside one chunk
        // therefore still produces exactly one `100`.
        let pct = (body.len() as u64 * 100 / (meta.body_len.max(1)) as u64) as u16;
        if painted != Some(pct) {
            session.notify(&ui::percent(pct)).await?;
            painted = Some(pct);
        }
    }

    // A zero-length body never enters the loop, so the bar would otherwise never be
    // cleared off the instrument's display.
    if painted != Some(100) {
        session.notify(&ui::percent(100)).await?;
    }
    session
        .request(Service::Program, 10, cmd::END_TRANSFER, &args)
        .await?;
    Ok((meta, body))
}

/// Bound on polling `0x26` for the cleaning pass, which normally finishes within a
/// second; the headroom is for a heavily churned library.
const CLEANING_POLLS: u32 = 120;
const CLEANING_POLL_SPACING: std::time::Duration = std::time::Duration::from_millis(250);

/// Reclaim `blocks` of library space and wait for the pass to finish ("Cleaning..."
/// on the display). Writing before it finishes is refused `0x1e`.
async fn clean_library<T: Transport>(
    session: &mut Session<'_, T, ReadWrite>,
    blocks: u32,
) -> Result<()> {
    session.notify(&ui::label("Cleaning...")?).await?;
    session.notify(&ui::percent(0)).await?;
    session
        .request(
            Service::Program,
            10,
            cmd::WRITE_PREPARE,
            &blocks.to_be_bytes(),
        )
        .await?;

    for polls in 0..CLEANING_POLLS {
        if polls > 0 {
            crate::sleep::sleep(CLEANING_POLL_SPACING).await;
        }
        let resp = session
            .request(Service::Program, 10, cmd::WRITE_PREPARE_2, &[])
            .await?;
        let p = resp.payload();
        if p.len() >= 12 {
            // Ready is `running` returning to 0; `done` can end above the request.
            let running = u32::from_be_bytes(p[8..12].try_into().unwrap());
            if running == 0 {
                return Ok(());
            }
        }
    }
    Err(Error::Transport(format!(
        "the library's cleaning pass did not report ready within {} polls",
        CLEANING_POLLS
    )))
}

/// Write an entity into a slot; one shape for every class. `name` is what the slot
/// ends up called — the file carries none, and a placeholder becomes the slot's name.
pub async fn write<T: Transport>(
    session: &mut Session<'_, T, ReadWrite>,
    at: Location,
    file: &[u8],
    name: &str,
    timestamp: u32,
) -> Result<()> {
    let file = envelope::unwrap(file)?;
    let body = &file.body.0;

    if matches!(session.class(), ObjectClass::Piano | ObjectClass::Sample) {
        // A library write is refused 0x16 unless a prepared block exists per
        // LIBRARY_BLOCK of body; reclaim exactly the shortfall.
        let needed = body.len().div_ceil(LIBRARY_BLOCK) as u32;
        let free = status(session).await?.free;
        if needed > free {
            clean_library(session, needed - free).await?;
        }
    }

    session.notify(&ui::label("Downloading...")?).await?;

    let mut begin = Vec::new();
    at.write_to(&mut begin);
    begin.extend_from_slice(&(body.len() as u32).to_be_bytes());
    begin.extend_from_slice(&file.header.tag);
    begin.extend_from_slice(&timestamp.to_be_bytes());
    begin.extend_from_slice(&u32::MAX.to_be_bytes());
    begin.extend_from_slice(&(name.len() as u32).to_be_bytes());
    begin.extend_from_slice(name.as_bytes());
    session
        .request(Service::Program, 10, cmd::BEGIN_WRITE, &begin)
        .await?;

    let mut offset = 0usize;
    while offset < body.len() {
        let end = (offset + write_chunk()).min(body.len());
        let chunk = &body[offset..end];
        let mut data = Vec::new();
        at.write_to(&mut data);
        data.extend_from_slice(&(offset as u32).to_be_bytes());
        data.extend_from_slice(&(chunk.len() as u32).to_be_bytes());
        data.extend_from_slice(chunk);
        if end == body.len() {
            // Only the final chunk is acknowledged.
            session
                .request(Service::Program, 10, cmd::WRITE_DATA, &data)
                .await?;
        } else {
            let msg = Message::new(Service::Program, 10, cmd::WRITE_DATA, data);
            session.notify(&msg).await?;
        }
        offset = end;
    }

    session.notify(&ui::percent(100)).await?;

    let mut args = Vec::new();
    at.write_to(&mut args);
    session
        .request(Service::Program, 10, cmd::END_TRANSFER, &args)
        .await?;
    Ok(())
}

/// Load a stored object live on the instrument ("open on device" / double-click in
/// NSM). The device switches to it immediately.
///
/// **Non-destructive** — nothing stored changes, so this needs no [`ReadWrite`] session.
/// This is the one command with inverted parity (`0x2f` request, `0x30` response).
pub async fn select<T: Transport, C>(session: &mut Session<'_, T, C>, at: Location) -> Result<()> {
    let mut args = Vec::new();
    at.write_to(&mut args);
    session
        .request(Service::Program, 10, cmd::SELECT, &args)
        .await?;
    Ok(())
}

/// Release anything the instrument is still holding from an abandoned session.
///
/// **Operator-driven, and deliberately not automatic.** Two faults hide behind "the
/// instrument is broken", and each is one frame to cure:
///
/// - An abandoned **UI** session (`HELLO` with no `GOODBYE`) makes the device answer
///   every slot in every class as **empty** — a wrong answer that looks like a right one.
///   Nothing detects it, because nothing fails. A bare `GOODBYE` clears it.
/// - An abandoned **class** session makes it refuse operations with status `0x12`.
///   A bare `SESSION_CLOSE` clears that, and [`Session::open`] already does it.
///
/// Both are sent **bare** — no session wrapped around them — because the session
/// machinery is exactly what is broken. Both are best-effort: sending them to a healthy
/// instrument is harmless, so this needs no diagnosis first.
///
/// This is not folded into [`Session::open`] on purpose. NSM sends no such frame, the
/// golden replays pin our exchanges against real captures, and quietly diverging from
/// that ground truth to paper over an operator-caused fault would cost more than it saves.
/// Read and discard anything the device still has queued, until it goes quiet.
///
/// Unread replies are how the stream gets out of step; nothing here writes, so it is safe
/// on a healthy instrument — it simply finds nothing.
async fn drain<T: Transport>(transport: &mut T) -> Result<()> {
    for _ in 0..DRAIN_CAP {
        match transport
            .read_timeout(crate::transport::READ_BUFFER, DRAIN_LIMIT)
            .await?
        {
            Some(_) => continue,
            None => break,
        }
    }
    Ok(())
}

/// How long to wait for a straggler before deciding the stream is quiet.
const DRAIN_LIMIT: std::time::Duration = std::time::Duration::from_millis(300);

/// Upper bound on stragglers, so a device that will not stop talking cannot hang this.
const DRAIN_CAP: usize = 16;

pub async fn recover<T: Transport>(transport: &mut T) -> Result<()> {
    // Drain first. A reply nobody read leaves the stream one message ahead, so every
    // later request is answered by the *previous* one's reply — the tell is an error
    // naming two commands that are one apart. Sending anything before draining keeps the
    // offset intact, which is why the two frames below cannot cure it on their own.
    drain(transport).await?;

    // ⚠️ Bounded reads: the instrument this is for is the one that has stopped
    // answering, and no reply to either frame is the expected outcome, not a failure.
    let goodbye = Message::new(Service::Ui, ui::SUBSYSTEM, ui::GOODBYE, Vec::new());
    transport.write(&goodbye.encode()).await?;
    let _ = transport
        .read_timeout(crate::transport::READ_BUFFER, DRAIN_LIMIT)
        .await?;

    let close = Message::new(Service::Program, 10, cmd::SESSION_CLOSE, Vec::new());
    transport.write(&close.encode()).await?;
    let _ = transport
        .read_timeout(crate::transport::READ_BUFFER, DRAIN_LIMIT)
        .await?;
    Ok(())
}

/// Every storage partition the device reports. **Read-only.**
///
/// The index of each entry is its object class code, so this is also the authoritative
/// answer to "what classes does this instrument have" — including the `(Native)` library
/// views that have no [`ObjectClass`] name.
pub async fn partitions<T: Transport, C>(
    session: &mut Session<'_, T, C>,
) -> Result<Vec<Partition>> {
    let resp = session
        .request(Service::Program, 10, cmd::PARTITIONS, &[])
        .await?;
    Partition::decode_all(&resp)
}

/// One partition's banks and their slot capacities. **Read-only.**
pub async fn banks<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    partition: u32,
) -> Result<Vec<Bank>> {
    let resp = session
        .request(Service::Program, 10, cmd::BANKS, &partition.to_be_bytes())
        .await?;
    Bank::decode_all(&resp)
}

/// Whether an address exists on this instrument, per the device's own geometry.
///
/// **Read-only**, and the point is that it answers *before* anything is attempted: a write
/// to a bad address otherwise fails only once the transfer is under way, and a write to an
/// occupied one is refused with status `0x4` after the caller has committed to it.
///
/// `Ok(None)` means the address is fine. `Ok(Some(reason))` explains why it is not, in
/// terms of the bank names the instrument itself uses — which for pianos are categories,
/// so "no bank 7 (this class has 6: Grand, Upright, …)" is a far better error than a
/// status code.
pub async fn check_address<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    at: Location,
) -> Result<Option<String>> {
    let banks = banks(session, session.class().to_raw()).await?;
    let Some(bank) = banks.get(at.bank as usize) else {
        let names: Vec<&str> = banks.iter().map(|b| b.name.as_str()).collect();
        return Ok(Some(format!(
            "bank {} does not exist; this class has {} ({})",
            at.bank + 1,
            banks.len(),
            names.join(", ")
        )));
    };
    // The `(Native)` partitions report a sentinel rather than a capacity, so there is
    // nothing to check against there.
    if bank.is_bounded() && at.slot >= bank.slots {
        return Ok(Some(format!(
            "\"{}\" holds {} slots, so slot {} is out of range",
            bank.name,
            bank.slots,
            at.slot + 1
        )));
    }
    Ok(None)
}

/// The object the panel currently has loaded, for the session's class. **Read-only.**
///
/// The read half of [`select`]: together they make the player's own position addressable.
pub async fn focus<T: Transport, C>(session: &mut Session<'_, T, C>) -> Result<Location> {
    let resp = session
        .request(Service::Program, 10, cmd::FOCUS, &[])
        .await?;
    let p = resp.payload();
    if p.len() < 8 {
        return Err(Error::Truncated {
            got: p.len(),
            need: 8,
        });
    }
    Ok(Location {
        bank: u32::from_be_bytes(p[0..4].try_into().unwrap()),
        slot: u32::from_be_bytes(p[4..8].try_into().unwrap()),
    })
}

/// Device status refusing a [`cmd::NEXT_SLOT`] without the direction word. Surfaced
/// rather than swallowed: a refused walk must not pass off a partial list.
pub const ENUMERATION_DISABLED: u32 = 0x11;

/// Slot value meaning "from the bank's boundary": the bank's first occupied slot when
/// walking forward, its last when walking backward.
pub const SLOT_BOUNDARY: u32 = 0xffff_ffff;

/// The next occupied slot after `at`, or `None` once the walk runs off the end.
///
/// **Read-only.** Positions inside a gap are safe to pass: the device answers with the
/// next real object rather than an error, which is what makes this an iterator over
/// content instead of over addresses. `at.slot == SLOT_BOUNDARY` starts from before
/// the bank's first slot.
pub async fn next_occupied<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    at: Location,
) -> Result<Option<Location>> {
    let mut args = Vec::new();
    at.write_to(&mut args);
    // Direction, 0 = forward; omitting it is refused after any write since power-up.
    args.extend_from_slice(&0u32.to_be_bytes());
    match session
        .request(Service::Program, 10, cmd::NEXT_SLOT, &args)
        .await
    {
        Ok(resp) => {
            let p = resp.payload();
            if p.len() < 8 {
                return Err(Error::Truncated {
                    got: p.len(),
                    need: 8,
                });
            }
            Ok(Some(Location {
                bank: u32::from_be_bytes(p[0..4].try_into().unwrap()),
                slot: u32::from_be_bytes(p[4..8].try_into().unwrap()),
            }))
        }
        // Not a fault: the position asked about is past the end, which is how the walk
        // terminates. A refusal leaves the session in step, so the caller may continue.
        Err(Error::DeviceStatus(1)) => Ok(None),
        Err(e) => Err(e),
    }
}

/// Every occupied slot in the session's class, in address order.
///
/// **Read-only.** [`next_occupied`] walks *within* one bank and stops at its end, so this
/// drives it bank by bank, each from [`SLOT_BOUNDARY`]. Pianos span several banks and
/// programs fill eight of them; only the sample library is flat, and walking bank 0
/// alone silently reports a fraction of the class.
///
/// Each bank's slot 0 is tested with [`info`] first, because the cursor cannot say
/// whether a bank *exists*: an empty bank and a bank the class does not have answer a
/// boundary request identically. `info` distinguishes — status `3` (out of range) means
/// the class has no more banks and ends the walk, status `1` a bank that merely holds
/// nothing — the sample library has addressable empty banks past its only populated one.
///
/// Two bounds keep a walk finite when the device does not behave as expected: `cap` on
/// total slots, and a stop after `EMPTY_BANKS_BEFORE_STOP` consecutive empty banks for
/// classes that never report out-of-range at all.
///
/// A refusal mid-walk — [`ENUMERATION_DISABLED`] above all — propagates as its error
/// rather than truncating the list: a partial inventory that looks complete is the one
/// result worse than none.
pub async fn occupied_slots<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    cap: usize,
) -> Result<Vec<Location>> {
    let mut found: Vec<Location> = Vec::new();
    let mut empty_banks = 0;

    for bank in 0..MAX_BANKS {
        if found.len() >= cap {
            break;
        }
        match info(session, Location { bank, slot: 0 }).await {
            Ok(_) | Err(Error::DeviceStatus(1)) => {}
            Err(Error::DeviceStatus(3)) => break,
            Err(e) => return Err(e),
        }

        let before = found.len();
        let mut at = Location {
            bank,
            slot: SLOT_BOUNDARY,
        };
        while found.len() < cap {
            match next_occupied(session, at).await? {
                // Staying inside the bank and moving forward, or the walk is not making
                // progress and would spin.
                Some(next)
                    if next.bank == bank && (at.slot == SLOT_BOUNDARY || next.slot > at.slot) =>
                {
                    found.push(next);
                    at = next;
                }
                _ => break,
            }
        }

        if found.len() == before {
            empty_banks += 1;
            if empty_banks >= EMPTY_BANKS_BEFORE_STOP {
                break;
            }
        } else {
            empty_banks = 0;
        }
    }
    Ok(found)
}

/// Highest bank number a walk will try. Programs use eight; nothing observed uses more.
const MAX_BANKS: u32 = 64;

/// How many consecutive empty banks end a walk, for classes whose banks stay addressable
/// past the last populated one instead of reporting out-of-range.
const EMPTY_BANKS_BEFORE_STOP: u32 = 2;

/// The library objects an entity actually needs. **Read-only.**
///
/// [`dependencies`] returns what the device reports, which includes rows that are not
/// dependencies at all — see [`Dependency::is_required`]. This is the one to build on;
/// reach for the unfiltered list only when the extra rows are themselves the subject.
pub async fn required_dependencies<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    at: Location,
) -> Result<Vec<Dependency>> {
    Ok(dependencies(session, at)
        .await?
        .into_iter()
        .filter(Dependency::is_required)
        .collect())
}

/// List the piano/sample library objects an entity depends on, as the device reports
/// them — including rows that are not dependencies at all.
///
/// **Read-only.** The returned [`Dependency`] ids match the ids the objects carry in
/// their own files, which is the bridge between wire content and file bytes.
pub async fn dependencies<T: Transport, C>(
    session: &mut Session<'_, T, C>,
    at: Location,
) -> Result<Vec<Dependency>> {
    let mut args = Vec::new();
    at.write_to(&mut args);
    let resp = session
        .request(Service::Program, 10, cmd::DEPENDENCIES, &args)
        .await?;
    Dependency::decode_all(&resp)
}

/// Move an object from one slot to another. The device relocates it internally — no
/// body crosses the wire.
///
/// An occupied destination is **swapped, not overwritten**: its occupant ends up in the
/// source slot, byte-identical. Nothing is destroyed, and no delete-first step is needed
/// (unlike a write, which the device refuses into an occupied slot with status `0x4`).
/// Confirmed on hardware.
///
/// Requires a [`ReadWrite`] session. Class-generalised: works for whichever object
/// class the session opened (programs, set lists).
pub async fn move_object<T: Transport>(
    session: &mut Session<'_, T, ReadWrite>,
    from: Location,
    to: Location,
) -> Result<()> {
    let mut args = Vec::new();
    from.write_to(&mut args);
    to.write_to(&mut args);
    session
        .request(Service::Program, 10, cmd::MOVE, &args)
        .await?;
    Ok(())
}

/// Delete the object in a slot. Requires a [`ReadWrite`] session.
///
/// Sends the `"Deleting..."` progress label the instrument paints, then the delete —
/// exactly the two OUT frames NSM sends (the `O36 O26 I30` shape).
pub async fn delete<T: Transport>(
    session: &mut Session<'_, T, ReadWrite>,
    at: Location,
) -> Result<()> {
    session.notify(&ui::label("Deleting...")?).await?;
    let mut args = Vec::new();
    at.write_to(&mut args);
    session
        .request(Service::Program, 10, cmd::DELETE, &args)
        .await?;
    Ok(())
}

/// Rename the object in a slot. Requires a [`ReadWrite`] session.
///
/// The name is sent big-endian length-prefixed and unpadded — the same encoding
/// strings use everywhere on the wire.
pub async fn rename<T: Transport>(
    session: &mut Session<'_, T, ReadWrite>,
    at: Location,
    name: &str,
) -> Result<()> {
    let mut args = Vec::new();
    at.write_to(&mut args);
    args.extend_from_slice(&(name.len() as u32).to_be_bytes());
    args.extend_from_slice(name.as_bytes());
    session
        .request(Service::Program, 10, cmd::RENAME, &args)
        .await?;
    Ok(())
}

/// Duplicate the object at `from` into `to`. Requires a [`ReadWrite`] session.
///
/// A deep copy the device performs internally: the arguments are just the two
/// addresses, and no body crosses the wire. (NSM follows a copy with `INFO`/`DEPENDENCIES`
/// reads to repaint its browser; those are UI bookkeeping and are not sent here — see
/// the module-level note.)
pub async fn duplicate<T: Transport>(
    session: &mut Session<'_, T, ReadWrite>,
    from: Location,
    to: Location,
) -> Result<()> {
    let mut args = Vec::new();
    from.write_to(&mut args);
    to.write_to(&mut args);
    session
        .request(Service::Program, 10, cmd::COPY, &args)
        .await?;
    Ok(())
}