use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use nord_usb::op;
use nord_usb::transport::Transport;
use nord_usb::wire::{Location, ProgramInfo, Status};
use nord_usb::{op as usb_op, ObjectClass, Session};
use crate::slot::{addr, shown};
use crate::ui::Ui;
pub enum Source {
Usb,
Replay(PathBuf),
}
pub fn status(ui: &Ui, source: Source, json: bool) -> Result<(), String> {
let report = match source {
Source::Usb => {
let mut transport = open_usb()?;
collect(&mut transport)?
}
Source::Replay(path) => {
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut transport = nord_usb::ReplayTransport::from_script(&text)
.map_err(|e| e.to_string())?
.lenient();
collect(&mut transport)?
}
};
if report.is_empty() {
return Err(
"no object class answered — either the instrument is not in a usable \
session state (a power cycle clears it), or the connection failed. \
`nord device info` shows what is on the bus."
.into(),
);
}
if json {
print_json(ui, &report);
} else {
print_table(ui, &report);
}
Ok(())
}
pub fn info(ui: &Ui) -> Result<(), String> {
let devices = nord_usb::transport::usb::list().map_err(|e| e.to_string())?;
if devices.is_empty() {
return Err("no Clavia device found".into());
}
for (i, d) in devices.iter().enumerate() {
if i > 0 {
ui.out("");
}
ui.out(format!(
" product: {}",
d.product_string().unwrap_or("(none reported)")
));
ui.out(format!(
" vendor: {} ({:#06x})",
d.manufacturer_string().unwrap_or("(none reported)"),
d.vendor_id(),
));
ui.out(format!(" product id: {:#06x}", d.product_id()));
ui.out(format!(
" serial: {}",
d.serial_number().unwrap_or("(none reported)")
));
let vendor_iface = d.interfaces().any(|i| i.class() == 0xff);
ui.out(format!(
" protocol: {}",
if vendor_iface {
"vendor interface present"
} else {
"no vendor interface — this tool cannot drive it"
}
));
if vendor_iface {
match nord_usb::transport::UsbTransport::open(d).and_then(|t| t.identity()) {
Ok(id) => {
ui.out(format!(
" firmware: {}",
crate::summary::version_label(u32::from(id.firmware))
));
ui.out(format!(" build: {}", id.build));
ui.out(format!(" max xfer: {} bytes", id.max_transfer));
}
Err(e) => ui.out(format!(" firmware: {}", ui.dim(e.to_string()))),
}
}
}
Ok(())
}
fn collect<T: Transport>(transport: &mut T) -> Result<Vec<Status>, String> {
nord_usb::block_on(op::inventory(transport)).map_err(|e| e.to_string())
}
fn print_table(ui: &Ui, report: &[Status]) {
ui.out(ui.dim(format!(
"{:<10} {:>20} {:>7} {}",
"class", "used", "full", "of"
)));
let mut any_variable = false;
for s in report {
let (used, of) = match s.slots() {
Some(slots) => (
format!("{} / {} slots", s.count, slots),
format!("{} blocks each", s.blocks_per_item().unwrap_or(0)),
),
None => {
any_variable = true;
(
format!("{} / {} blocks", s.used, s.total()),
format!("{} items", s.count),
)
}
};
ui.out(format!(
"{:<10} {:>20} {:>6.1}% {}",
s.class.label(),
used,
s.used_percent(),
ui.dim(of),
));
}
if any_variable {
ui.note("");
ui.note("(blocks are a device-internal unit, not bytes)");
}
}
fn print_json(ui: &Ui, report: &[Status]) {
ui.out("[");
for (i, s) in report.iter().enumerate() {
let comma = if i + 1 == report.len() { "" } else { "," };
ui.out(format!(
" {{\"class\": \"{}\", \"code\": {}, \"items\": {}, \"used\": {}, \"free\": {}, \"capacity\": {}}}{comma}",
s.class.label(),
s.class.to_raw(),
s.count,
s.used,
s.free,
s.total(),
));
}
ui.out("]");
}
fn finish<T>(
result: Result<T, nord_usb::Error>,
closed: Result<(), nord_usb::Error>,
) -> Result<T, nord_usb::Error> {
match result {
Ok(v) => closed.map(|()| v),
Err(e) => Err(e),
}
}
fn explain(e: nord_usb::Error, at: Location) -> String {
match e {
nord_usb::Error::DeviceStatus(1) => {
format!("{} is empty", shown(at))
}
nord_usb::Error::DeviceStatus(3) => {
format!("{} is out of range for this instrument", shown(at))
}
nord_usb::Error::DeviceStatus(4) => {
format!(
"{} is occupied, and the instrument does not overwrite in place",
shown(at)
)
}
other => other.to_string(),
}
}
fn explain_pair(e: nord_usb::Error, from: Location, to: Location) -> String {
match e {
nord_usb::Error::DeviceStatus(1) => explain(e, from),
nord_usb::Error::DeviceStatus(4) => explain(e, to),
nord_usb::Error::DeviceStatus(3) => format!(
"{} or {} is out of range for this instrument",
shown(from),
shown(to)
),
other => other.to_string(),
}
}
fn explain_walk(e: nord_usb::Error) -> String {
match e {
nord_usb::Error::DeviceStatus(usb_op::ENUMERATION_DISABLED) => {
"the instrument refused the enumeration request as malformed (status 0x11) \
— it refuses a cursor request without the direction word after any write \
since power-up. nord sends the full form, so this should not happen; \
per-slot `info` still works in the meantime"
.into()
}
other => other.to_string(),
}
}
static RECORDING: OnceLock<Option<PathBuf>> = OnceLock::new();
pub fn set_recording(path: Option<PathBuf>) {
let _ = RECORDING.set(path);
}
fn open_usb() -> Result<nord_usb::transport::UsbTransport, String> {
let t = nord_usb::transport::UsbTransport::open_first().map_err(|e| e.to_string())?;
match RECORDING.get().and_then(Option::as_deref) {
Some(path) => t.recording_to(path).map_err(|e| e.to_string()),
None => Ok(t),
}
}
fn read_object(
t: &mut nord_usb::transport::UsbTransport,
at: Location,
class: ObjectClass,
body: bool,
) -> Result<(ProgramInfo, Vec<u8>), String> {
nord_usb::block_on(async {
let mut s = Session::open(t, class).await?;
let r = async {
let info = usb_op::info(&mut s, at).await?;
let file = if body {
usb_op::read_body(&mut s, at).await?
} else {
usb_op::read_program(&mut s, at).await?
};
Ok::<_, nord_usb::Error>((info, file))
}
.await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(|e| explain(e, at))
}
pub fn get(
ui: &Ui,
at: Location,
out: Option<PathBuf>,
class: ObjectClass,
body: bool,
) -> Result<(), String> {
if body && out.is_none() {
return Err("--body writes a file; give -o a path".into());
}
let mut t = open_usb()?;
let (info, file) = read_object(&mut t, at, class, body)?;
if let Some(path) = out {
std::fs::write(&path, &file).map_err(|e| format!("{}: {e}", path.display()))?;
ui.note(format!(
"read {:?} ({} bytes) from {} -> {}",
info.name,
file.len(),
shown(at),
path.display(),
));
return Ok(());
}
let entity = nord_format::from_stream(&mut std::io::Cursor::new(&file)).map_err(|e| {
format!(
"{} decoded off the device but did not parse: {e}",
shown(at)
)
})?;
ui.out(format!(
"{} {} {:?} ({}, version {})",
shown(at),
ui.dash(),
info.name,
info.format,
info.version
));
crate::summary::print(ui, &entity);
Ok(())
}
pub fn sweep(
ui: &Ui,
at: Location,
dir: PathBuf,
class: ObjectClass,
body: bool,
) -> Result<(), String> {
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut t = open_usb()?;
ui.note(format!(
"sweeping {} ({}) into {}",
shown(at),
class.label(),
dir.display()
));
ui.note("change one thing on the instrument, then say what it was");
ui.note("each prompt reopens with your last answer, editable; clear it to finish");
let mut captured = 0usize;
let mut previous = String::new();
while let Some(label) = ui.ask("what changed", &previous)? {
previous = label.clone();
let stem = match stem(&label) {
Ok(s) => s,
Err(e) => {
ui.warn(e);
continue;
}
};
if taken(&dir, &stem) {
ui.warn(format!(
"{stem:?} is already captured; give this one another name"
));
continue;
}
let (info, file) = match read_object(&mut t, at, class, body) {
Ok(read) => read,
Err(e) => {
ui.warn(e);
continue;
}
};
let path = dir.join(match body {
true => format!("{stem}.bin"),
false => format!("{stem}.{}", info.format),
});
std::fs::write(&path, &file).map_err(|e| format!("{}: {e}", path.display()))?;
captured += 1;
ui.note(format!(" {} ({} bytes)", path.display(), file.len()));
}
ui.note(format!("captured {captured} file(s) in {}", dir.display()));
Ok(())
}
fn stem(label: &str) -> Result<String, String> {
let mut owed = false;
let mut out = String::with_capacity(label.len());
for c in label.chars() {
match c {
'-' => owed = !out.is_empty(),
_ if c.is_whitespace() => owed = !out.is_empty(),
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => owed = !out.is_empty(),
_ if c.is_control() => {}
_ => {
if std::mem::take(&mut owed) {
out.push('-');
}
out.push(c);
}
}
}
let out = out.trim_matches(['.', '-']);
if out.is_empty() {
return Err(format!("{label:?} leaves nothing usable as a filename"));
}
Ok(out.to_string())
}
fn taken(dir: &Path, stem: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries
.flatten()
.any(|e| Path::new(&e.file_name()).file_stem() == Some(OsStr::new(stem)))
}
pub fn put(
ui: &Ui,
path: PathBuf,
at: Location,
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let file = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
nord_usb::envelope::unwrap(&file).map_err(|e| e.to_string())?;
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.filter(|s| !s.is_empty() && !s.starts_with('.'))
.ok_or_else(|| {
format!(
"{}: the slot takes its name from the file's stem, and this file has none",
path.display()
)
})?;
let stamp = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as u32);
send(
ui,
&file,
at,
class,
confirmed,
&path.display().to_string(),
Some(&stem),
stamp,
)
}
macro_rules! one_shot {
($t:expr, $class:expr, |$s:ident| $body:expr) => {
nord_usb::block_on(async {
let mut $s = Session::open($t, $class).await?.allow_destructive_writes();
let r = $body.await;
let closed = $s.commit().await;
r.and(closed)
})
};
}
#[allow(clippy::too_many_arguments)]
pub fn send(
ui: &Ui,
file: &[u8],
at: Location,
class: ObjectClass,
confirmed: bool,
what: &str,
name: Option<&str>,
stamp: Option<u32>,
) -> Result<(), String> {
let mut t = open_usb()?;
let bad = nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = usb_op::check_address(&mut s, at).await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(|e| explain(e, at))?;
if let Some(reason) = bad {
return Err(format!("{}: {reason}", shown(at)));
}
let existing = nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = usb_op::info(&mut s, at).await;
let closed = s.commit().await;
finish(r, closed)
});
let existing = match existing {
Ok(info) => Some(info),
Err(nord_usb::Error::DeviceStatus(1)) => None,
Err(e) => return Err(explain(e, at)),
};
match &existing {
Some(info) => {
ui.note(format!(
"about to {} {} (currently {:?}) with {what}",
ui.danger("overwrite"),
shown(at),
info.name,
));
ui.note(format!(
" {} the instrument will not overwrite in place, so {} is deleted first. \
Its {} bytes are read back beforehand and put back if the write fails.",
ui.danger("note:"),
shown(at),
info.body_len,
));
}
None => ui.note(format!("{} is empty; writing {what}", shown(at))),
}
if let Some(name) = name.filter(|n| !n.is_empty()) {
ui.note(format!("the slot will be named {name:?}"));
}
ui.confirm(confirmed)?;
let backup = match &existing {
Some(_) => Some(
nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = usb_op::read_program(&mut s, at).await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(|e| {
format!(
"could not read {} back before replacing it, so it was left alone: {}",
shown(at),
explain(e, at)
)
})?,
),
None => None,
};
if existing.is_some() {
ui.note(format!("deleting {} to make room", shown(at)));
one_shot!(&mut t, class, |s| usb_op::delete(&mut s, at))
.map_err(|e| format!("deleting {}: {}", shown(at), explain(e, at)))?;
}
let timestamp = stamp.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as u32)
.unwrap_or(0)
});
let write_name = name
.map(str::to_string)
.or_else(|| existing.as_ref().map(|i| i.name.clone()))
.unwrap_or_default();
let written = if fail_after_delete() {
Err(nord_usb::Error::Transport(
"NORD_FAIL_AFTER_DELETE was set, so the write was not attempted".into(),
))
} else {
one_shot!(&mut t, class, |s| usb_op::write(
&mut s,
at,
file,
&write_name,
timestamp
))
};
match (written, backup) {
(Ok(()), _) => {
ui.note(format!("wrote {what} -> {}", shown(at)));
Ok(())
}
(Err(e), None) => Err(e.to_string()),
(Err(e), Some(backup)) => {
ui.warn(format!(
"the write failed and {} is now empty; putting the original back",
shown(at)
));
let restore_name = existing
.as_ref()
.map(|i| i.name.clone())
.unwrap_or_else(|| write_name.clone());
match one_shot!(&mut t, class, |s| usb_op::write(
&mut s,
at,
&backup,
&restore_name,
timestamp
)) {
Ok(()) => {
ui.note(format!("restored {}", shown(at)));
Err(format!(
"{e} ({} was restored, and is unchanged)",
shown(at)
))
}
Err(restore) => Err(rescue(
ui,
at,
&backup,
&e.to_string(),
&restore.to_string(),
)),
}
}
}
}
#[cfg(feature = "fault-injection")]
fn fail_after_delete() -> bool {
std::env::var_os("NORD_FAIL_AFTER_DELETE").is_some()
}
#[cfg(not(feature = "fault-injection"))]
fn fail_after_delete() -> bool {
false
}
fn rescue(ui: &Ui, at: Location, backup: &[u8], write: &str, restore: &str) -> String {
let path = std::env::current_dir()
.unwrap_or_default()
.join(rescue_name(at, backup));
match std::fs::write(&path, backup) {
Ok(()) => {
ui.warn(format!(
"restore failed too; wrote the original to {}",
path.display()
));
format!(
"{write} (restoring failed as well: {restore}) {} is empty; \
its former contents were saved to {} — put it back with `nord put`",
shown(at),
path.display(),
)
}
Err(io) => format!(
"{write} (restoring failed as well: {restore}) {} is EMPTY and its former \
contents could not be saved either ({io}); {} bytes are lost",
shown(at),
backup.len(),
),
}
}
fn peek(
t: &mut nord_usb::transport::UsbTransport,
class: ObjectClass,
at: Location,
) -> Result<String, String> {
nord_usb::block_on(async {
let mut s = Session::open(t, class).await?;
let r = usb_op::info(&mut s, at).await;
let closed = s.commit().await;
finish(r, closed).map(|info| info.name)
})
.map_err(|e| explain(e, at))
}
enum DestFate {
Overwritten,
Swapped,
}
fn peek_dest(
ui: &Ui,
t: &mut nord_usb::transport::UsbTransport,
class: ObjectClass,
at: Location,
fate: DestFate,
) -> String {
match (peek(t, class, at), fate) {
(Ok(name), DestFate::Overwritten) => format!("{} {name:?}", ui.danger("OVERWRITING")),
(Ok(name), DestFate::Swapped) => format!("{} {name:?}", ui.bold("SWAPPING WITH")),
(Err(_), _) => "destination reads as empty".into(),
}
}
pub fn move_object(
ui: &Ui,
from: Location,
to: Location,
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let mut t = open_usb()?;
let name = peek(&mut t, class, from)?;
let dest = peek_dest(ui, &mut t, class, to, DestFate::Swapped);
ui.note(format!(
"moving {:?} from {} to {} {} {}",
name,
shown(from),
shown(to),
ui.dash(),
dest
));
ui.confirm(confirmed)?;
nord_usb::block_on(async {
let mut s = Session::open(&mut t, class)
.await?
.allow_destructive_writes();
let r = usb_op::move_object(&mut s, from, to).await;
r.and(s.commit().await)
})
.map_err(|e| explain_pair(e, from, to))?;
ui.note(format!("moved {} -> {}", shown(from), shown(to)));
Ok(())
}
pub fn delete(
ui: &Ui,
slots: &[Location],
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let mut t = open_usb()?;
for &at in slots {
let name = peek(&mut t, class, at)?;
ui.note(format!(
"{} {:?} at {}",
ui.danger("deleting"),
name,
shown(at)
));
}
ui.confirm(confirmed)?;
let mut done = 0;
let outcome = nord_usb::block_on(async {
let mut s = Session::open(&mut t, class)
.await
.map_err(|e| (slots[0], e))?
.allow_destructive_writes();
let mut r = Ok(());
for &at in slots {
r = usb_op::delete(&mut s, at).await.map_err(|e| (at, e));
if r.is_err() {
break;
}
done += 1;
}
match (r, s.commit().await) {
(Err(e), _) => Err(e),
(Ok(()), Err(e)) => Err((slots[slots.len() - 1], e)),
(Ok(()), Ok(())) => Ok(()),
}
});
if let Err((at, e)) = outcome {
let gone: Vec<String> = slots[..done].iter().map(|&at| shown(at)).collect();
return Err(match done {
0 => format!("deleting {}: {}", shown(at), explain(e, at)),
_ => format!(
"deleting {}: {} — {} already deleted ({}); {} left alone",
shown(at),
explain(e, at),
done,
gone.join(", "),
slots.len() - done - 1
),
});
}
ui.note(format!("deleted {} item(s)", slots.len()));
Ok(())
}
pub fn rename(
ui: &Ui,
at: Location,
name: String,
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let mut t = open_usb()?;
let old = peek(&mut t, class, at)?;
ui.note(format!(
"renaming {} from {:?} to {:?}",
shown(at),
old,
name
));
ui.confirm(confirmed)?;
nord_usb::block_on(async {
let mut s = Session::open(&mut t, class)
.await?
.allow_destructive_writes();
let r = usb_op::rename(&mut s, at, &name).await;
r.and(s.commit().await)
})
.map_err(|e| explain(e, at))?;
ui.note(format!("renamed {} -> {:?}", shown(at), name));
Ok(())
}
pub fn duplicate(
ui: &Ui,
from: Location,
to: Location,
class: ObjectClass,
confirmed: bool,
) -> Result<(), String> {
let mut t = open_usb()?;
let name = peek(&mut t, class, from)?;
let dest = peek_dest(ui, &mut t, class, to, DestFate::Overwritten);
ui.note(format!(
"duplicating {:?} from {} to {} {} {}",
name,
shown(from),
shown(to),
ui.dash(),
dest
));
ui.confirm(confirmed)?;
nord_usb::block_on(async {
let mut s = Session::open(&mut t, class)
.await?
.allow_destructive_writes();
let r = usb_op::duplicate(&mut s, from, to).await;
r.and(s.commit().await)
})
.map_err(|e| explain_pair(e, from, to))?;
ui.note(format!("duplicated {} -> {}", shown(from), shown(to)));
Ok(())
}
pub fn select(ui: &Ui, at: Location, class: ObjectClass) -> Result<(), String> {
let mut t = open_usb()?;
nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = usb_op::select(&mut s, at).await;
let closed = s.commit().await;
r.and(closed)
})
.map_err(|e| explain(e, at))?;
ui.note(format!("selected {} on the instrument", shown(at)));
Ok(())
}
pub(crate) fn grouped(n: u32) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
pub(crate) fn human_size(n: u32) -> Option<String> {
const UNITS: [&str; 3] = ["KiB", "MiB", "GiB"];
if n < 1024 {
return None;
}
let mut value = n as f64 / 1024.0;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
Some(format!("{value:.1} {}", UNITS[unit]))
}
pub fn deps(ui: &Ui, at: Location, class: ObjectClass) -> Result<(), String> {
let mut t = open_usb()?;
let deps = nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = usb_op::dependencies(&mut s, at).await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(|e| explain(e, at))?;
let (live, idle): (Vec<_>, Vec<_>) = deps.iter().partition(|d| d.flag == 1);
let (live, unassigned): (Vec<_>, Vec<_>) = live.into_iter().partition(|d| d.is_required());
if live.is_empty() {
ui.note(format!("{} depends on nothing", shown(at)));
} else {
ui.out(ui.dim(format!("{:<8} {:<10} name", "class", "id")));
for d in &live {
let loc = match d.location.map(shown) {
Some(at) => format!(" {}", ui.dim(at)),
None => String::new(),
};
ui.out(format!(
"{:<8} {:08x} {}{loc}",
d.class.label(),
d.id,
d.name.trim_end(),
));
}
}
if !unassigned.is_empty() {
let which: Vec<String> = unassigned
.iter()
.map(|d| d.class.label().to_string())
.collect();
ui.note("");
ui.note(format!("routed but nothing assigned: {}", which.join(", ")));
}
if !idle.is_empty() {
ui.note("");
ui.note(format!(
"{} further row(s) reported but not in use — the section is not routed to a \
keyboard part, so the instrument names an object this object does not depend on:",
idle.len()
));
for d in &idle {
let named = if d.name.trim_end().is_empty() {
"(no name)".to_string()
} else {
d.name.trim_end().to_string()
};
ui.note(format!(" {} {:08x} {}", d.class.label(), d.id, named));
}
}
Ok(())
}
pub fn recover(ui: &Ui) -> Result<(), String> {
let mut t = open_usb()?;
nord_usb::block_on(usb_op::recover(&mut t)).map_err(|e| e.to_string())?;
ui.note("released any session the instrument was still holding");
ui.note("if slots were reading as empty, re-check them now");
Ok(())
}
pub fn geometry(ui: &Ui) -> Result<(), String> {
let mut t = open_usb()?;
let rows = nord_usb::block_on(async {
let mut s = Session::open(&mut t, ObjectClass::Program).await?;
let r = async {
let parts = usb_op::partitions(&mut s).await?;
let mut rows = Vec::new();
for p in parts {
let banks = usb_op::banks(&mut s, p.index).await?;
rows.push((p, banks));
}
Ok(rows)
}
.await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(|e| e.to_string())?;
ui.out(ui.dim(format!(
"{:<4} {:<18} {:>6} {:>7} banks",
"code", "partition", "banks", "slots"
)));
for (p, banks) in &rows {
let bounded: Vec<&nord_usb::wire::Bank> = banks.iter().filter(|b| b.is_bounded()).collect();
let slots = if bounded.len() == banks.len() {
bounded.iter().map(|b| b.slots).sum::<u32>().to_string()
} else {
"—".to_string()
};
let names: Vec<&str> = banks.iter().map(|b| b.name.as_str()).collect();
ui.out(format!(
"{:<4} {:<18} {:>6} {:>7} {}",
p.index,
p.name,
banks.len(),
slots,
ui.dim(names.join(", ")),
));
}
ui.note("");
ui.note("the partition index is the object class number; (Native) partitions are a");
ui.note("second view of the same library, so their capacity is a sentinel, not a size");
Ok(())
}
#[cfg(feature = "wedge")]
pub fn wedge(ui: &Ui, class: ObjectClass, yes: bool) -> Result<(), String> {
if !yes {
return Err("refusing to wedge the instrument without --yes; \
clear it afterwards with `nord device recover`"
.into());
}
let mut t = open_usb()?;
nord_usb::block_on(async {
let s = Session::open(&mut t, class).await?;
s.abort();
Ok::<(), nord_usb::Error>(())
})
.map_err(|e| e.to_string())?;
ui.note("session abandoned with no GOODBYE — the instrument is now wedged");
ui.note("every slot will read as empty, and read *successfully*, until you run");
ui.note("`nord device recover`");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn controls(
ui: &Ui,
from: u8,
to: u8,
len: usize,
interface: bool,
value: u16,
index: u16,
) -> Result<(), String> {
if from > to {
return Err(format!(
"--from {from:#04x} is above --to {to:#04x}; nothing to sweep"
));
}
let t = open_usb()?;
let recipient = if interface {
nord_usb::transport::usb::Recipient::Interface
} else {
nord_usb::transport::usb::Recipient::Device
};
ui.out(ui.dim(format!("{:<9} {:>5} {}", "bRequest", "bytes", "response")));
let mut answered = 0;
for request in from..=to {
let got = t.vendor_control_in(
recipient,
request,
value,
index,
len,
std::time::Duration::from_millis(500),
);
match got {
Ok(data) if data.is_empty() => {
answered += 1;
ui.out(format!(
"{request:#04x} ({request:>3}) {:>5} (accepted, no data)",
0
));
}
Ok(data) => {
answered += 1;
let hex: Vec<String> = data.iter().take(24).map(|b| format!("{b:02x}")).collect();
let text: String = data
.iter()
.map(|&b| {
if (0x20..0x7f).contains(&b) {
b as char
} else {
'.'
}
})
.collect();
ui.out(format!(
"{request:#04x} ({request:>3}) {:>5} {}",
data.len(),
hex.join(" ")
));
ui.out(format!("{:>16} {}", "", ui.dim(text)));
}
Err(_) => ui.out(ui.dim(format!("{request:#04x} ({request:>3}) - —"))),
}
}
ui.note("");
ui.note(format!(
"{answered} of {} request(s) answered",
u16::from(to) - u16::from(from) + 1
));
Ok(())
}
pub fn focus(ui: &Ui, class: ObjectClass) -> Result<(), String> {
let mut t = open_usb()?;
let (at, info) = nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = async {
let at = usb_op::focus(&mut s).await?;
let info = match usb_op::info(&mut s, at).await {
Ok(i) => Some(i),
Err(nord_usb::Error::DeviceStatus(1)) => None,
Err(e) => return Err(e),
};
Ok((at, info))
}
.await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(|e| e.to_string())?;
match info {
Some(info) => ui.out(format!("{} {:?}", addr(at), info.name)),
None => ui.out(format!("{} (empty)", addr(at))),
}
Ok(())
}
pub fn list(ui: &Ui, class: ObjectClass, cap: usize) -> Result<(), String> {
let mut t = open_usb()?;
let rows = nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = async {
let mut rows = Vec::new();
for at in usb_op::occupied_slots(&mut s, cap).await? {
match usb_op::info(&mut s, at).await {
Ok(info) => rows.push((at, info)),
Err(nord_usb::Error::DeviceStatus(1)) => {}
Err(e) => return Err(e),
}
}
Ok(rows)
}
.await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(explain_walk)?;
if rows.is_empty() {
ui.note(format!("no {} on the instrument", class.label()));
return Ok(());
}
ui.out(ui.dim(format!(
"{:<8} {:<6} {:>9} name",
"slot", "format", "bytes"
)));
for (at, info) in &rows {
ui.out(format!(
"{:<8} {:<6} {:>9} {}",
addr(*at),
info.format,
info.body_len,
info.name.trim_end(),
));
}
ui.note("");
ui.note(format!("{} {}", rows.len(), class.label()));
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn probe(
ui: &Ui,
class: ObjectClass,
op: u32,
args: &[u32],
wait: u64,
yes: bool,
bare: bool,
service: u32,
subsystem: u32,
) -> Result<(), String> {
let mut words = Vec::with_capacity(args.len() * 4);
for a in args {
words.extend_from_slice(&a.to_be_bytes());
}
if op == nord_usb::wire::cmd::DELETING_WEDGE || op == nord_usb::wire::cmd::NOTIFY_READ_WEDGE {
ui.note(format!(
"{op:#04x} is known to wedge the instrument (no reply, session lost, \
power cycle to recover); nothing stored has ever been harmed by it"
));
}
ui.note(format!(
"probing command {op:#04x} on {} with {} argument word(s)",
class.label(),
args.len()
));
if !yes {
return Err("refusing to probe without --yes".into());
}
if op == u32::MAX {
return Err(format!(
"{op:#x} has no `op + 1` reply code; the command space ends one below it"
));
}
let svc = nord_usb::Service::from_raw(service);
let mut t = open_usb()?;
if bare {
let reply = nord_usb::block_on(async {
let req = nord_usb::Message::new(svc, subsystem, op, words.clone());
t.write(&req.encode()).await?;
match t
.read_timeout(
nord_usb::transport::READ_BUFFER,
std::time::Duration::from_secs(wait),
)
.await?
{
Some(raw) => nord_usb::Message::decode_response(&raw).map(Some),
None => Ok(None),
}
})
.map_err(|e: nord_usb::Error| e.to_string())?;
match reply {
Some(reply) => report_reply(ui, &reply, op),
None => ui.out(format!("no reply within {wait}s")),
}
return Ok(());
}
let (reply, changed, close_failed) = nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = s
.probe(
svc,
subsystem,
op,
&words,
std::time::Duration::from_secs(wait),
)
.await;
let changed = s.instrument_changed();
let closed = s.commit().await;
r.map(|reply| (reply, changed, closed.err()))
})
.map_err(|e| e.to_string())?;
if changed {
ui.note("the instrument reported a change during this session");
}
if let Some(e) = close_failed {
ui.note(format!("the session would not close afterwards: {e}"));
}
let Some(reply) = reply else {
ui.out(format!(
"no reply within {wait}s — the device ignored command {op:#04x}"
));
return Ok(());
};
report_reply(ui, &reply, op);
Ok(())
}
fn report_reply(ui: &Ui, reply: &nord_usb::Message, op: u32) {
ui.out(format!(
"reply command {:#04x}{}",
reply.command,
if reply.command == op + 1 {
String::new()
} else {
format!(" (expected {:#04x} by the +1 rule)", op + 1)
}
));
match reply.status() {
Some(0) => ui.out("status 0 (ok)".to_string()),
Some(code) => ui.out(format!("status {code} ({code:#x}) — not success")),
None => ui.out("status absent — reply too short to carry one".to_string()),
}
let payload = reply.payload();
ui.out(format!("payload {} bytes", payload.len()));
for (i, chunk) in payload.chunks(16).enumerate() {
let hex: Vec<String> = chunk.iter().map(|b| format!("{b:02x}")).collect();
let ascii: String = chunk
.iter()
.map(|&b| {
if (0x20..0x7f).contains(&b) {
b as char
} else {
'.'
}
})
.collect();
ui.out(format!(" {:04x} {:<47} {ascii}", i * 16, hex.join(" ")));
}
}
pub fn slot_info(ui: &Ui, at: Location, class: ObjectClass) -> Result<(), String> {
let mut t = open_usb()?;
let info = nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = usb_op::info(&mut s, at).await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(|e| explain(e, at))?;
let row = |label: &str, value: String| {
ui.out(format!(" {}{value}", ui.dim(format!("{label:<11}"))));
};
row("location:", shown(info.location));
row("name:", format!("{:?}", info.name));
row("format:", info.format.clone());
row("version:", info.version.to_string());
row(
"body:",
format!(
"{} bytes{}",
grouped(info.body_len),
match human_size(info.body_len) {
Some(h) => format!(" {}", ui.dim(format!("({h})"))),
None => String::new(),
}
),
);
match info.crc32 {
Some(crc) => row("crc32:", format!("{crc:#010x}")),
None => row(
"crc32:",
format!("none {}", ui.dim("(not checksummed for this class)")),
),
}
Ok(())
}
pub fn fetch(at: Location, class: ObjectClass) -> Result<Vec<u8>, String> {
let mut t = open_usb()?;
nord_usb::block_on(async {
let mut s = Session::open(&mut t, class).await?;
let r = usb_op::read_program(&mut s, at).await;
let closed = s.commit().await;
finish(r, closed)
})
.map_err(|e| explain(e, at))
}
fn rescue_name(at: Location, backup: &[u8]) -> String {
let format = backup
.get(8..12)
.filter(|tag| tag.iter().all(|b| b.is_ascii_alphanumeric()))
.map(|tag| String::from_utf8_lossy(tag).into_owned())
.unwrap_or_else(|| "bin".to_string());
format!("nord-rescued-{}-{}.{format}", at.bank + 1, at.slot + 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rescued_slot_is_named_for_its_location_and_format() {
let mut file = vec![0u8; 45];
file[0..4].copy_from_slice(b"CBIN");
file[4..8].copy_from_slice(&1u32.to_le_bytes());
file[8..12].copy_from_slice(b"ne5p");
let at = Location { bank: 6, slot: 49 };
assert_eq!(rescue_name(at, &file), "nord-rescued-7-50.ne5p");
}
#[test]
fn the_format_tag_comes_from_the_bytes() {
let mut file = vec![0u8; 45];
file[8..12].copy_from_slice(b"ne5t");
let at = Location { bank: 0, slot: 3 };
assert_eq!(rescue_name(at, &file), "nord-rescued-1-4.ne5t");
}
#[test]
fn unparseable_bytes_still_get_rescued() {
let at = Location { bank: 0, slot: 0 };
assert_eq!(rescue_name(at, b"nonsense"), "nord-rescued-1-1.bin");
}
#[test]
fn a_swept_capture_keeps_the_words_it_was_described_with() {
assert_eq!(stem("split point C4").unwrap(), "split-point-C4");
assert_eq!(stem(" transpose +1 ").unwrap(), "transpose-+1");
assert_eq!(stem("organ vol 5 -> 6").unwrap(), "organ-vol-5-6");
}
#[test]
fn a_swept_name_cannot_leave_the_output_directory() {
assert_eq!(stem("../../etc/passwd").unwrap(), "etc-passwd");
assert_eq!(stem("rotary:fast").unwrap(), "rotary-fast");
assert_eq!(stem(".hidden").unwrap(), "hidden");
}
#[test]
fn an_answer_with_no_filename_in_it_is_refused() {
for bad in ["...", "/", " ", "?*", "-"] {
assert!(stem(bad).is_err(), "{bad:?}");
}
}
}