use std::num::NonZeroU32;
use std::sync::mpsc::Sender;
use std::time::Duration;
use eframe::egui;
use nord_usb::session::ReadWrite;
use nord_usb::transport::Transport;
use nord_usb::wire::{Bank, Dependency, ProgramInfo};
use nord_usb::{op, Error, Location, ObjectClass, Session};
use super::{DeviceCmd, DeviceEvent, Outgoing};
use crate::strings::shown;
use crate::workspace::Origin;
macro_rules! one_session {
($t:expr, $class:expr, $changed:expr, |$s:ident| $body:block) => {
one_session!(@run Session::open($t, $class).await?, $changed, |$s| $body)
};
(write $t:expr, $class:expr, $changed:expr, |$s:ident| $body:block) => {
one_session!(
@run Session::open($t, $class).await?.allow_destructive_writes(),
$changed,
|$s| $body
)
};
(@run $open:expr, $changed:expr, |$s:ident| $body:block) => {{
#[allow(unused_mut)]
let mut $s = $open;
let result = async { $body }.await;
*$changed |= $s.instrument_changed();
let closed = $s.commit().await;
finish(result, closed)
}};
}
#[derive(Clone)]
pub struct Emit {
tx: Sender<DeviceEvent>,
ctx: egui::Context,
}
impl Emit {
pub fn new(tx: Sender<DeviceEvent>, ctx: egui::Context) -> Emit {
Emit { tx, ctx }
}
pub fn send(&self, event: DeviceEvent) {
let _ = self.tx.send(event);
self.ctx.request_repaint();
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Flow {
Continue,
Released,
Lost,
}
fn hung_up(e: &Error) -> bool {
matches!(e, Error::Transport(_))
}
fn spoil(gone: &mut bool, at: Option<Location>) -> impl FnOnce(Error) -> String + '_ {
move |e| {
*gone |= hung_up(&e);
match at {
Some(at) => explain(e, at),
None => e.to_string(),
}
}
}
pub async fn run<T: Transport>(transport: &mut T, cmd: DeviceCmd, emit: &Emit) -> Flow {
if matches!(cmd, DeviceCmd::Disconnect) {
return Flow::Released;
}
let what = cmd.label();
emit.send(DeviceEvent::Started(what.clone()));
let mut changed = false;
let mut gone = false;
let result = execute(transport, cmd, emit, &mut changed, &mut gone).await;
if changed {
emit.send(DeviceEvent::InstrumentChanged);
}
match result {
Ok(Some(note)) => emit.send(DeviceEvent::OpOk(note)),
Ok(None) => {}
Err(e) => emit.send(DeviceEvent::OpFailed(format!("{what}: {e}"))),
}
emit.send(DeviceEvent::Finished);
match gone {
true => Flow::Lost,
false => Flow::Continue,
}
}
async fn execute<T: Transport>(
t: &mut T,
cmd: DeviceCmd,
emit: &Emit,
changed: &mut bool,
gone: &mut bool,
) -> Result<Option<String>, String> {
match cmd {
DeviceCmd::Disconnect => Ok(None),
DeviceCmd::ScanBank {
class,
bank,
slots: count,
} => {
let slots = scan_bank(t, class, bank, count, changed)
.await
.map_err(spoil(gone, None))?;
let filled = slots.iter().filter(|s| s.is_some()).count();
let note = format!(
"bank {bank}: {filled} of {} slots hold something",
slots.len()
);
emit.send(DeviceEvent::BankScanned { class, bank, slots });
Ok(Some(note))
}
DeviceCmd::ScanClass {
class,
slots,
banks,
} => {
let walked = scan_class(t, class, slots, banks, emit, changed)
.await
.map_err(spoil(gone, None))?;
Ok(Some(format!(
"{}: {} banks, {} items, {}, one session",
class.label(),
walked.banks,
walked.items,
walked.how,
)))
}
DeviceCmd::SlotInfo { class, at } => {
let info = match slot_info(t, class, at, changed).await {
Ok(info) => Some(info),
Err(Error::DeviceStatus(1)) => None,
Err(e) => return Err(spoil(gone, Some(at))(e)),
};
emit.send(DeviceEvent::SlotInfo { class, at, info });
Ok(None)
}
DeviceCmd::Deps { class, at } => {
let deps = dependencies(t, class, at, changed)
.await
.map_err(spoil(gone, Some(at)))?;
let note = format!("{}: {} dependencies", shown(at), deps.len());
emit.send(DeviceEvent::Deps { class, at, deps });
Ok(Some(note))
}
DeviceCmd::Get {
class,
at,
body,
open,
} => {
let (info, bytes) = read_object(t, class, at, body, changed)
.await
.map_err(spoil(gone, Some(at)))?;
let note = format!(
"read {:?} from {} ({} bytes)",
info.name,
shown(at),
bytes.len()
);
emit.send(DeviceEvent::Got {
name: entity_name(&info, body),
origin: Origin::Device { class, at },
bytes,
open,
});
Ok(Some(note))
}
DeviceCmd::Put {
id,
class,
at,
name,
bytes,
} => {
let note = put_one(t, class, at, &name, bytes, emit, changed, gone)
.await
.map_err(spoil(gone, Some(at)))??;
emit.send(DeviceEvent::Sent { id, class, at });
Ok(Some(note))
}
DeviceCmd::SendAll { class, items } => send_all(t, class, items, emit, changed, gone).await,
DeviceCmd::Select { class, at } => {
select(t, class, at, changed)
.await
.map_err(spoil(gone, Some(at)))?;
Ok(Some(format!("selected {} on the instrument", shown(at))))
}
DeviceCmd::Rename { class, at, name } => {
rename(t, class, at, &name, changed)
.await
.map_err(spoil(gone, Some(at)))?;
Ok(Some(format!("renamed {} to {name:?}", shown(at))))
}
DeviceCmd::Move { class, from, to } => {
move_object(t, class, from, to, changed)
.await
.map_err(spoil(gone, Some(from)))?;
Ok(Some(format!("moved {} -> {}", shown(from), shown(to))))
}
DeviceCmd::Duplicate { class, from, to } => {
duplicate(t, class, from, to, changed)
.await
.map_err(spoil(gone, Some(from)))?;
Ok(Some(format!("duplicated {} -> {}", shown(from), shown(to))))
}
DeviceCmd::Delete { class, at } => {
delete(t, class, at, changed)
.await
.map_err(spoil(gone, Some(at)))?;
Ok(Some(format!("deleted {}", shown(at))))
}
}
}
async fn put<T: Transport>(
s: &mut Session<'_, T, ReadWrite>,
at: Location,
what: &str,
bytes: Vec<u8>,
emit: &Emit,
gone: &mut bool,
) -> Result<Result<String, String>, Error> {
if let Ok(Some(why)) = op::check_address(s, at).await {
return Ok(Err(format!("{}: {why}", shown(at))));
}
let class = s.class();
let timestamp = unix_now()?;
let existing = match op::info(s, at).await {
Ok(info) => Some(info),
Err(Error::DeviceStatus(1)) => None,
Err(e) => return Ok(Err(spoil(gone, Some(at))(e))),
};
let backup = match existing {
Some(_) => match op::read_program(s, at).await {
Ok(file) => Some(file),
Err(e) => {
return Ok(Err(format!(
"could not read {} back before replacing it, so it was left alone: {}",
shown(at),
spoil(gone, Some(at))(e)
)))
}
},
None => None,
};
if backup.is_some() && !class.overwrites_in_place() {
emit.send(DeviceEvent::Note(format!(
"deleting {} to make room",
shown(at)
)));
if let Err(e) = op::delete(s, at).await {
return Ok(Err(format!(
"deleting {}: {}",
shown(at),
spoil(gone, Some(at))(e)
)));
}
}
let written = op::write(s, at, &bytes, "0", timestamp).await;
Ok(match (written, backup) {
(Ok(()), _) => Ok(name_slot(s, at, what, emit, gone).await),
(Err(e), None) => Err(spoil(gone, Some(at))(e)),
(Err(e), Some(backup)) => {
emit.send(DeviceEvent::OpFailed(format!(
"the write failed and {}; putting the original back",
aftermath(class, at)
)));
match op::write(s, at, &backup, "0", timestamp).await {
Ok(()) => Err(format!(
"{e} ({} was restored, and is unchanged)",
shown(at)
)),
Err(restore) => {
*gone |= hung_up(&restore);
let name = rescue_name(at, &backup);
emit.send(DeviceEvent::Rescued {
at,
name,
bytes: backup,
});
Err(format!(
"{e} (restoring failed as well: {restore}); {}, and its former \
contents are now in the local list as a rescued entity — \
put it back",
aftermath(class, at)
))
}
}
}
})
}
fn aftermath(class: ObjectClass, at: Location) -> String {
match class.overwrites_in_place() {
true => format!("{} may hold a partly written body", shown(at)),
false => format!("{} is empty", shown(at)),
}
}
async fn name_slot<T: Transport>(
s: &mut Session<'_, T, ReadWrite>,
at: Location,
what: &str,
emit: &Emit,
gone: &mut bool,
) -> String {
let wrote = format!("wrote {what} -> {}", shown(at));
if !s.class().names_its_slots() {
return wrote;
}
let Some(label) = slot_label(what) else {
return wrote;
};
match op::rename(s, at, &label).await {
Ok(()) => format!("{wrote}, named {label:?}"),
Err(e) => {
let why = spoil(gone, Some(at))(e);
emit.send(DeviceEvent::OpFailed(format!(
"{} holds the right bytes, but naming it {label:?} failed: {why}",
shown(at)
)));
wrote
}
}
}
fn slot_label(name: &str) -> Option<String> {
const LONGEST: usize = 64;
let mut label = name.trim();
if let Some((stem, tag)) = label.rsplit_once('.') {
let is_tag = (2..=5).contains(&tag.len())
&& tag.chars().all(|c| c.is_ascii_alphanumeric())
&& tag.chars().any(|c| c.is_ascii_alphabetic());
if is_tag && !stem.trim().is_empty() {
label = stem;
}
}
let label = label.trim();
if label.is_empty() {
return None;
}
let end = (0..=LONGEST.min(label.len()))
.rev()
.find(|end| label.is_char_boundary(*end))?;
Some(label[..end].trim_end().to_string())
}
#[allow(clippy::too_many_arguments)]
async fn put_one<T: Transport>(
t: &mut T,
class: ObjectClass,
at: Location,
what: &str,
bytes: Vec<u8>,
emit: &Emit,
changed: &mut bool,
gone: &mut bool,
) -> Result<Result<String, String>, Error> {
one_session!(write t, class, changed, |s| {
put(&mut s, at, what, bytes, emit, gone).await
})
}
#[allow(clippy::too_many_arguments)]
async fn send_all<T: Transport>(
t: &mut T,
class: ObjectClass,
items: Vec<Outgoing>,
emit: &Emit,
changed: &mut bool,
gone: &mut bool,
) -> Result<Option<String>, String> {
let total = items.len();
let mut done = 0;
let outcome = batch(t, class, &items, total, &mut done, emit, changed, gone).await;
let refusal = outcome.map_err(spoil(gone, None))?;
match refusal {
None => Ok(Some(format!(
"wrote {done} of {total} to {}",
class.label()
))),
Some(why) => Err(format!(
"{why} — {done} of {total} were written; the rest are still waiting"
)),
}
}
#[allow(clippy::too_many_arguments)]
async fn batch<T: Transport>(
t: &mut T,
class: ObjectClass,
items: &[Outgoing],
total: usize,
done: &mut usize,
emit: &Emit,
changed: &mut bool,
gone: &mut bool,
) -> Result<Option<String>, Error> {
one_session!(write t, class, changed, |s| {
for item in items {
emit.send(DeviceEvent::Note(format!(
"sending {:?} to {} ({} of {total})",
item.name,
shown(item.at),
*done + 1
)));
match put(&mut s, item.at, &item.name, item.bytes.clone(), emit, gone).await? {
Ok(note) => {
*done += 1;
emit.send(DeviceEvent::OpOk(note));
emit.send(DeviceEvent::Sent {
id: item.id,
class,
at: item.at,
});
}
Err(why) => return Ok::<Option<String>, Error>(Some(why)),
}
}
Ok(None)
})
}
fn finish<T>(result: Result<T, Error>, closed: Result<(), Error>) -> Result<T, Error> {
match result {
Ok(v) => closed.map(|()| v),
Err(e) => Err(e),
}
}
fn explain(e: Error, at: Location) -> String {
match e {
Error::DeviceStatus(1) => format!("{} is empty", shown(at)),
Error::DeviceStatus(3) => format!("{} is out of range for this instrument", shown(at)),
Error::DeviceStatus(4) => format!(
"{} is occupied, and the instrument does not overwrite in place",
shown(at)
),
other => other.to_string(),
}
}
async fn slot_info<T: Transport>(
t: &mut T,
class: ObjectClass,
at: Location,
changed: &mut bool,
) -> Result<ProgramInfo, Error> {
let mut s = Session::open(t, class).await?;
let r = op::info(&mut s, at).await;
*changed |= s.instrument_changed();
let closed = s.commit().await;
finish(r, closed)
}
const SCAN_READ_LIMIT: Duration = Duration::from_secs(10);
const MOST_OCCUPIED: usize = 4096;
const VACANT_RUN: u32 = 32;
async fn scan_bank<T: Transport>(
t: &mut T,
class: ObjectClass,
bank: u32,
slots: u32,
changed: &mut bool,
) -> Result<Vec<Option<ProgramInfo>>, Error> {
one_session!(t, class, changed, |s| {
s.set_read_limit(SCAN_READ_LIMIT);
walk_bank(&mut s, bank, slots).await
})
}
struct Planned {
bank: NonZeroU32,
slots: Option<u32>,
}
struct Walked {
banks: u32,
items: usize,
how: &'static str,
}
async fn scan_class<T: Transport>(
t: &mut T,
class: ObjectClass,
per_bank: u32,
cap: u32,
emit: &Emit,
changed: &mut bool,
) -> Result<Walked, Error> {
let mut banks = 0;
let mut items = 0;
let mut how = "slot by slot";
let counted = one_session!(t, class, changed, |s| {
s.set_read_limit(SCAN_READ_LIMIT);
let status = op::status(&mut s).await?;
let held = status.count;
let geometry = match op::banks(&mut s, class.to_raw()).await {
Ok(geometry) => Some(geometry),
Err(Error::DeviceStatus(_)) => None,
Err(e) => return Err(e),
};
let counted = status.slots().map(|slots| slots.div_ceil(per_bank));
let (plan, ends_known) = match &geometry {
Some(geometry) => (planned(geometry), true),
None => (
guessed(counted.unwrap_or(cap).min(cap), per_bank),
counted.is_some(),
),
};
let expected = match geometry.is_some() {
true => Some(plan.len() as u32),
false => counted,
};
if let Some(geometry) = geometry {
emit.send(DeviceEvent::Geometry {
class,
banks: geometry,
});
}
emit.send(DeviceEvent::ClassStatus {
class,
status,
banks: expected,
});
match op::focus(&mut s).await {
Ok(at) => emit.send(DeviceEvent::Focus {
class,
at: Some(at),
}),
Err(Error::DeviceStatus(1)) => emit.send(DeviceEvent::Focus { class, at: None }),
Err(Error::DeviceStatus(_)) => {}
Err(e) => return Err(e),
}
let capacity: Option<u32> = plan.iter().map(|planned| planned.slots).sum();
let sparse = capacity.is_none_or(|capacity| worth_the_cursor(held, capacity));
let found = match ends_known && sparse {
true => occupied(&mut s, cap_slots(&plan, held)).await?,
false => None,
};
if let Some(found) = found {
how = "by cursor";
for planned in &plan {
let slots = shape(&found, planned);
banks += 1;
items += slots.iter().filter(|slot| slot.is_some()).count();
emit.send(DeviceEvent::BankScanned {
class,
bank: planned.bank.get(),
slots,
});
}
return Ok::<(), Error>(());
}
for planned in &plan {
let slots = match planned.slots {
Some(capacity) => walk_bank(&mut s, planned.bank.get(), capacity).await?,
None => walk_open_bank(&mut s, planned.bank.get()).await?,
};
if slots.is_empty() && !ends_known {
break;
}
let short = planned
.slots
.is_some_and(|asked| slots.len() as u32 != asked);
banks += 1;
items += slots.iter().filter(|slot| slot.is_some()).count();
emit.send(DeviceEvent::BankScanned {
class,
bank: planned.bank.get(),
slots,
});
if short && !ends_known {
break;
}
}
Ok::<(), Error>(())
});
counted.map(|()| Walked { banks, items, how })
}
fn planned(geometry: &[Bank]) -> Vec<Planned> {
geometry
.iter()
.map(|bank| Planned {
bank: bank
.index
.checked_add(1)
.and_then(NonZeroU32::new)
.expect("a decoded bank index fits its panel number"),
slots: bank.is_bounded().then_some(bank.slots),
})
.collect()
}
fn guessed(banks: u32, per_bank: u32) -> Vec<Planned> {
(1..=banks)
.map(|bank| Planned {
bank: NonZeroU32::new(bank).expect("guessed banks start at one"),
slots: Some(per_bank),
})
.collect()
}
fn worth_the_cursor(held: u32, capacity: u32) -> bool {
capacity > 0 && held.saturating_mul(2) < capacity
}
fn cap_slots(plan: &[Planned], held: u32) -> usize {
let stated: Option<u32> = plan.iter().map(|planned| planned.slots).sum();
match stated {
Some(stated) => (stated as usize).max(held as usize),
None => MOST_OCCUPIED,
}
.clamp(1, MOST_OCCUPIED)
}
async fn occupied<T: Transport, C>(
s: &mut Session<'_, T, C>,
cap: usize,
) -> Result<Option<Vec<(Location, ProgramInfo)>>, Error> {
let found = match op::occupied_slots(s, cap).await {
Ok(found) => found,
Err(Error::DeviceStatus(_)) => return Ok(None),
Err(e) => return Err(e),
};
let mut out = Vec::with_capacity(found.len());
for at in found {
match op::info(s, at).await {
Ok(info) => out.push((at, info)),
Err(Error::DeviceStatus(1)) => {}
Err(e) => return Err(e),
}
}
Ok(Some(out))
}
fn shape(found: &[(Location, ProgramInfo)], planned: &Planned) -> Vec<Option<ProgramInfo>> {
let bank = planned.bank.get() - 1;
let mine: Vec<&(Location, ProgramInfo)> =
found.iter().filter(|(at, _)| at.bank == bank).collect();
let past = mine.iter().map(|(at, _)| at.slot + 1).max().unwrap_or(0);
let mut slots = vec![None; planned.slots.unwrap_or(past).max(past) as usize];
for (at, info) in mine {
if let Some(cell) = slots.get_mut(at.slot as usize) {
*cell = Some(info.clone());
}
}
slots
}
async fn walk_bank<T: Transport, C>(
s: &mut Session<'_, T, C>,
bank: u32,
slots: u32,
) -> Result<Vec<Option<ProgramInfo>>, Error> {
let mut out = Vec::new();
for slot in 1..=slots {
match op::info(s, Location::from_user(bank, slot)).await {
Ok(info) => out.push(Some(info)),
Err(Error::DeviceStatus(1)) => out.push(None),
Err(Error::DeviceStatus(3)) => break,
Err(e) => return Err(e),
}
}
Ok(out)
}
async fn walk_open_bank<T: Transport, C>(
s: &mut Session<'_, T, C>,
bank: u32,
) -> Result<Vec<Option<ProgramInfo>>, Error> {
let mut out = Vec::new();
let mut vacant = 0;
for slot in 1..=MOST_OCCUPIED as u32 {
match op::info(s, Location::from_user(bank, slot)).await {
Ok(info) => {
vacant = 0;
out.push(Some(info));
}
Err(Error::DeviceStatus(1)) => {
vacant += 1;
if vacant >= VACANT_RUN {
break;
}
out.push(None);
}
Err(Error::DeviceStatus(3)) => break,
Err(e) => return Err(e),
}
}
while matches!(out.last(), Some(None)) {
out.pop();
}
Ok(out)
}
async fn read_object<T: Transport>(
t: &mut T,
class: ObjectClass,
at: Location,
body: bool,
changed: &mut bool,
) -> Result<(ProgramInfo, Vec<u8>), Error> {
let mut s = Session::open(t, class).await?;
let r = async {
let info = op::info(&mut s, at).await?;
let file = if body {
op::read_body(&mut s, at).await?
} else {
op::read_program(&mut s, at).await?
};
Ok::<_, Error>((info, file))
}
.await;
*changed |= s.instrument_changed();
let closed = s.commit().await;
finish(r, closed)
}
async fn dependencies<T: Transport>(
t: &mut T,
class: ObjectClass,
at: Location,
changed: &mut bool,
) -> Result<Vec<Dependency>, Error> {
let mut s = Session::open(t, class).await?;
let r = op::dependencies(&mut s, at).await;
*changed |= s.instrument_changed();
let closed = s.commit().await;
finish(r, closed)
}
async fn select<T: Transport>(
t: &mut T,
class: ObjectClass,
at: Location,
changed: &mut bool,
) -> Result<(), Error> {
let mut s = Session::open(t, class).await?;
let r = op::select(&mut s, at).await;
*changed |= s.instrument_changed();
r.and(s.commit().await)
}
async fn rename<T: Transport>(
t: &mut T,
class: ObjectClass,
at: Location,
name: &str,
changed: &mut bool,
) -> Result<(), Error> {
let mut s = Session::open(t, class).await?.allow_destructive_writes();
let r = op::rename(&mut s, at, name).await;
*changed |= s.instrument_changed();
r.and(s.commit().await)
}
async fn move_object<T: Transport>(
t: &mut T,
class: ObjectClass,
from: Location,
to: Location,
changed: &mut bool,
) -> Result<(), Error> {
let mut s = Session::open(t, class).await?.allow_destructive_writes();
let r = op::move_object(&mut s, from, to).await;
*changed |= s.instrument_changed();
r.and(s.commit().await)
}
async fn duplicate<T: Transport>(
t: &mut T,
class: ObjectClass,
from: Location,
to: Location,
changed: &mut bool,
) -> Result<(), Error> {
let mut s = Session::open(t, class).await?.allow_destructive_writes();
let r = op::duplicate(&mut s, from, to).await;
*changed |= s.instrument_changed();
r.and(s.commit().await)
}
async fn delete<T: Transport>(
t: &mut T,
class: ObjectClass,
at: Location,
changed: &mut bool,
) -> Result<(), Error> {
let mut s = Session::open(t, class).await?.allow_destructive_writes();
let r = op::delete(&mut s, at).await;
*changed |= s.instrument_changed();
r.and(s.commit().await)
}
#[cfg(not(target_arch = "wasm32"))]
fn unix_now() -> Result<u32, Error> {
let elapsed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| {
Error::InvalidArgument(format!("system clock is before the Unix epoch: {e}"))
})?;
u32::try_from(elapsed.as_secs())
.map_err(|_| Error::InvalidArgument("system time does not fit the device protocol".into()))
}
#[cfg(target_arch = "wasm32")]
fn unix_now() -> Result<u32, Error> {
let seconds = js_sys::Date::now() / 1000.0;
if !(0.0..=f64::from(u32::MAX)).contains(&seconds) {
return Err(Error::InvalidArgument(
"system time does not fit the device protocol".into(),
));
}
Ok(seconds as u32)
}
fn entity_name(info: &ProgramInfo, body: bool) -> String {
let name = info.name.trim();
let name = match name.is_empty() {
true => "unnamed",
false => name,
};
match body {
true => format!("{name}.body"),
false => name.to_string(),
}
}
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.user_bank(),
at.user_slot()
)
}
#[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 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_read_keeps_the_slots_name_verbatim() {
let info = ProgramInfo {
location: Location { bank: 6, slot: 3 },
body_len: 121,
format: "ne5p".into(),
version: 4,
crc32: Some(0),
name: "Africa Split".into(),
};
assert_eq!(entity_name(&info, false), "Africa Split");
assert_eq!(entity_name(&info, true), "Africa Split.body");
}
#[test]
fn a_slot_is_named_what_this_computer_calls_the_object() {
let label = |name: &str| slot_label(name);
assert_eq!(label("Africa-Split.ne5p").as_deref(), Some("Africa-Split"));
assert_eq!(label("Squabble B.ne5t").as_deref(), Some("Squabble B"));
assert_eq!(label(" Rotary Fast ").as_deref(), Some("Rotary Fast"));
assert_eq!(label("Bass 2.0").as_deref(), Some("Bass 2.0"));
assert_eq!(label("Mr. Hammond").as_deref(), Some("Mr. Hammond"));
assert_eq!(
label(".ne5p").as_deref(),
Some(".ne5p"),
"a tag and nothing"
);
}
#[test]
fn a_name_with_nothing_in_it_is_not_sent() {
for nothing in ["", " ", "\t"] {
assert_eq!(slot_label(nothing), None, "{nothing:?}");
}
}
#[test]
fn a_long_name_is_cut_on_a_character_boundary() {
let long = "é".repeat(200);
let cut = slot_label(&long).expect("something is left");
assert!(cut.len() <= 64, "{} bytes", cut.len());
assert!(long.starts_with(&cut));
assert_eq!(cut.chars().count(), 32, "whole characters only");
}
#[test]
fn a_nameless_slot_still_gets_a_label() {
let info = ProgramInfo {
location: Location { bank: 0, slot: 0 },
body_len: 121,
format: "ne5p".into(),
version: 4,
crc32: None,
name: " ".into(),
};
assert_eq!(entity_name(&info, false), "unnamed");
}
#[test]
fn a_spaced_name_survives_to_the_rename() {
assert_eq!(slot_label("Big strings").as_deref(), Some("Big strings"));
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod wire_tests {
use std::collections::VecDeque;
use std::sync::mpsc::Receiver;
use super::*;
use nord_usb::wire::{cmd, ui, Message, Service};
use nord_usb::Transport;
struct Puppet {
heard: Vec<Message>,
replies: VecDeque<Vec<u8>>,
info: u32,
deaf: bool,
banks: Vec<(&'static str, u32)>,
reports_geometry: bool,
garbles_geometry: bool,
reports_counters: bool,
filled: Option<Vec<(Location, &'static str)>>,
enumerates: bool,
focus: Option<Location>,
}
const EIGHT_BANKS: [(&str, u32); 8] = [
("Bank 1", 50),
("Bank 2", 50),
("Bank 3", 50),
("Bank 4", 50),
("Bank 5", 50),
("Bank 6", 50),
("Bank 7", 50),
("Bank 8", 50),
];
impl Puppet {
fn new(info: u32) -> Puppet {
Puppet {
heard: Vec::new(),
replies: VecDeque::new(),
info,
deaf: false,
banks: EIGHT_BANKS.to_vec(),
reports_geometry: true,
garbles_geometry: false,
reports_counters: true,
filled: None,
enumerates: true,
focus: None,
}
}
fn deaf() -> Puppet {
Puppet {
deaf: true,
..Puppet::new(1)
}
}
fn stocked(banks: &[(&'static str, u32)], filled: &[(Location, &'static str)]) -> Puppet {
Puppet {
banks: banks.to_vec(),
filled: Some(filled.to_vec()),
..Puppet::new(1)
}
}
fn mute_about_geometry(mut self) -> Puppet {
self.reports_geometry = false;
self
}
fn mute_about_counters(mut self) -> Puppet {
self.reports_counters = false;
self
}
fn garbling_geometry(mut self) -> Puppet {
self.garbles_geometry = true;
self
}
fn no_enumeration(mut self) -> Puppet {
self.enumerates = false;
self
}
fn focused_on(mut self, at: Location) -> Puppet {
self.focus = Some(at);
self
}
fn holds(&self, at: Location) -> Option<&'static str> {
self.filled
.as_ref()?
.iter()
.find(|(held, _)| *held == at)
.map(|(_, name)| *name)
}
fn answer(&self, msg: &Message) -> Option<(u32, Vec<u8>)> {
if !matches!(msg.service, Service::Program) {
return None;
}
let at = || Location {
bank: u32::from_be_bytes(msg.args[0..4].try_into().unwrap()),
slot: u32::from_be_bytes(msg.args[4..8].try_into().unwrap()),
};
match msg.command {
cmd::STATUS if !self.reports_counters => Some((0, words(&[0, 0, 0]))),
cmd::STATUS => {
let count = self.filled.as_ref().map_or(0, Vec::len) as u32;
let total: u32 = self.banks.iter().map(|(_, slots)| slots).sum();
Some((0, words(&[count, total.saturating_sub(count), count])))
}
cmd::BANKS if !self.reports_geometry => Some((2, Vec::new())),
cmd::BANKS if self.garbles_geometry => Some((0, vec![0xff, 0xff])),
cmd::BANKS => {
let mut p = msg.args[0..4].to_vec();
p.push(self.banks.len() as u8);
for (name, slots) in &self.banks {
p.extend_from_slice(&(name.len() as u32).to_be_bytes());
p.extend_from_slice(name.as_bytes());
p.extend_from_slice(&slots.to_be_bytes());
}
Some((0, p))
}
cmd::FOCUS => match self.focus {
Some(at) => Some((0, words(&[at.bank, at.slot]))),
None => Some((1, Vec::new())),
},
cmd::NEXT_SLOT if !self.enumerates => Some((op::ENUMERATION_DISABLED, Vec::new())),
cmd::NEXT_SLOT => {
let from = at();
let Some(dir) = msg.args.get(8..12) else {
return Some((op::ENUMERATION_DISABLED, Vec::new()));
};
let backward = u32::from_be_bytes(dir.try_into().unwrap()) == 1;
let in_bank = self
.filled
.as_ref()
.into_iter()
.flatten()
.filter_map(|(held, _)| (held.bank == from.bank).then_some(held.slot));
let hit = if backward {
in_bank
.filter(|s| from.slot == op::SLOT_BOUNDARY || *s < from.slot)
.max()
} else {
in_bank
.filter(|s| from.slot == op::SLOT_BOUNDARY || *s > from.slot)
.min()
};
match hit {
Some(slot) => Some((0, words(&[from.bank, slot]))),
None => Some((1, words(&[from.bank, op::SLOT_BOUNDARY]))),
}
}
cmd::READ => {
let (offset, want) = (
u32::from_be_bytes(msg.args[8..12].try_into().unwrap()),
u32::from_be_bytes(msg.args[12..16].try_into().unwrap()),
);
let at = at();
let mut p = words(&[at.bank, at.slot, offset, want]);
p.resize(p.len() + want as usize, 0);
Some((0, p))
}
cmd::INFO => {
let at = at();
let capacity = self.banks.get(at.bank as usize).map(|(_, slots)| *slots);
if capacity.is_none_or(|slots| at.slot >= slots) {
return Some((3, Vec::new()));
}
match &self.filled {
Some(_) => match self.holds(at) {
Some(name) => Some((0, info_payload(at, name))),
None => Some((1, Vec::new())),
},
None => match self.info {
0 => Some((0, info_payload(at, "something"))),
status => Some((status, Vec::new())),
},
}
}
_ => None,
}
}
fn commands(&self) -> Vec<u32> {
self.heard
.iter()
.filter(|msg| matches!(msg.service, Service::Program))
.map(|msg| msg.command)
.collect()
}
fn first(&self, command: u32) -> Option<&Message> {
self.heard.iter().find(|msg| msg.command == command)
}
}
fn words(of: &[u32]) -> Vec<u8> {
of.iter().flat_map(|w| w.to_be_bytes()).collect()
}
fn info_payload(at: Location, name: &str) -> Vec<u8> {
let mut p = words(&[at.bank, at.slot, 121]);
p.extend_from_slice(b"ne5p");
p.extend_from_slice(&words(&[4, u32::MAX, u32::MAX, name.len() as u32]));
p.extend_from_slice(name.as_bytes());
p.extend_from_slice(&u32::MAX.to_be_bytes());
p
}
impl Transport for Puppet {
async fn write(&mut self, buf: &[u8]) -> nord_usb::Result<()> {
let msg = Message::decode(buf)?;
let spoken = matches!(msg.service, Service::Ui)
&& matches!(msg.command, ui::LABEL | ui::PERCENT);
let (status, payload) = match self.answer(&msg) {
Some(answered) => answered,
None => (0, vec![0; 32]),
};
if !spoken {
let mut args = status.to_be_bytes().to_vec();
args.extend_from_slice(&payload);
self.replies.push_back(
Message::new(msg.service, msg.subsystem, msg.command + 1, args).encode(),
);
}
self.heard.push(msg);
Ok(())
}
async fn read(&mut self, _max: usize) -> nord_usb::Result<Vec<u8>> {
if self.deaf {
return Err(Error::Transport("the device stopped answering".into()));
}
self.replies
.pop_front()
.ok_or_else(|| Error::Transport("nothing to read".into()))
}
}
fn a_program() -> Vec<u8> {
let ctx = egui::Context::default();
let mut workspace = crate::workspace::Workspace::new(ctx);
let mut log = crate::log::Log::default();
let id = workspace
.create(crate::workspace::Fresh::Program, &mut log)
.expect("a fresh default");
workspace.get(id).expect("just made").bytes.clone()
}
fn drive(device: &mut Puppet, cmd: DeviceCmd) -> (Flow, Receiver<DeviceEvent>) {
let (tx, events) = std::sync::mpsc::channel();
let emit = Emit::new(tx, egui::Context::default());
let flow = nord_usb::block_on(run(device, cmd, &emit));
(flow, events)
}
#[test]
fn a_put_names_the_slot_it_wrote_into() {
let at = Location { bank: 6, slot: 3 };
let mut device = Puppet::new(1);
let (flow, _) = drive(
&mut device,
DeviceCmd::Put {
id: 1,
class: ObjectClass::Program,
at,
name: "Africa-Split.ne5p".into(),
bytes: a_program(),
},
);
assert!(flow == Flow::Continue, "the instrument is still there");
let rename = device.first(cmd::RENAME).expect("the slot was named");
let mut expected = Vec::new();
at.write_to(&mut expected);
expected.extend_from_slice(&12u32.to_be_bytes());
expected.extend_from_slice(b"Africa-Split");
assert_eq!(
rename.args, expected,
"the location and the operator's name"
);
let commands = device.commands();
let order = |command| commands.iter().position(|held| *held == command);
assert!(order(cmd::RENAME) > order(cmd::WRITE_DATA), "{commands:x?}");
assert!(
order(cmd::RENAME) < order(cmd::SESSION_CLOSE),
"{commands:x?}"
);
assert!(
order(cmd::RENAME) > order(cmd::BEGIN_WRITE),
"{commands:x?}"
);
}
#[test]
fn a_put_into_a_buffer_class_never_deletes_the_slot() {
let at = Location { bank: 0, slot: 2 };
let put = |class| DeviceCmd::Put {
id: 1,
class,
at,
name: "Africa-Split.ne5p".into(),
bytes: a_program(),
};
let mut live = Puppet::stocked(&[("Live", 3)], &[(at, "Live 3")]);
let (flow, _) = drive(&mut live, put(ObjectClass::Live));
assert!(flow == Flow::Continue, "the instrument is still there");
assert_eq!(counted(&live, cmd::DELETE), 0, "nothing was emptied");
assert_eq!(counted(&live, cmd::BEGIN_WRITE), 1, "and the bytes went");
let mut program = Puppet::stocked(&[("Bank 1", 50)], &[(at, "Africa")]);
drive(&mut program, put(ObjectClass::Program));
assert_eq!(
counted(&program, cmd::DELETE),
1,
"a class that refuses an occupied slot still makes room"
);
}
#[test]
fn a_class_that_stores_no_name_is_not_renamed_after_a_write() {
let at = Location { bank: 0, slot: 2 };
let mut device = Puppet::stocked(&[("Live", 3)], &[(at, "Live 3")]);
let (flow, _) = drive(
&mut device,
DeviceCmd::Put {
id: 1,
class: ObjectClass::Live,
at,
name: "Africa-Split.ne5l".into(),
bytes: a_program(),
},
);
assert!(flow == Flow::Continue);
assert!(device.first(cmd::WRITE_DATA).is_some(), "the bytes went");
assert!(
device.first(cmd::RENAME).is_none(),
"the device would have said yes and done nothing"
);
}
#[test]
fn a_nameless_asset_still_gets_its_bytes_written() {
let mut device = Puppet::new(1);
let (flow, _) = drive(
&mut device,
DeviceCmd::Put {
id: 1,
class: ObjectClass::Program,
at: Location { bank: 6, slot: 3 },
name: " ".into(),
bytes: a_program(),
},
);
assert!(flow == Flow::Continue);
assert!(device.first(cmd::WRITE_DATA).is_some(), "the bytes went");
assert!(device.first(cmd::RENAME).is_none(), "nothing to name it");
}
#[test]
fn every_item_of_a_batch_is_named() {
let bytes = a_program();
let item = |slot, name: &str| Outgoing {
id: slot as u64,
at: Location { bank: 6, slot },
name: name.into(),
bytes: bytes.clone(),
};
let mut device = Puppet::new(1);
let (flow, _) = drive(
&mut device,
DeviceCmd::SendAll {
class: ObjectClass::Program,
items: vec![item(3, "Africa-Split.ne5p"), item(4, "Squabble-B.ne5p")],
},
);
assert!(flow == Flow::Continue);
let named: Vec<u32> = device
.commands()
.into_iter()
.filter(|command| *command == cmd::RENAME)
.collect();
assert_eq!(named.len(), 2, "one rename per item");
let opens = device
.commands()
.into_iter()
.filter(|command| *command == cmd::SESSION_OPEN)
.count();
assert_eq!(opens, 1);
}
#[test]
fn a_transport_that_fails_is_the_instrument_going_away() {
let (flow, _) = drive(
&mut Puppet::deaf(),
DeviceCmd::SlotInfo {
class: ObjectClass::Program,
at: Location { bank: 6, slot: 3 },
},
);
assert!(flow == Flow::Lost);
}
fn a_small_library() -> Puppet {
Puppet::stocked(
&[("Grand", 50), ("Upright", 30)],
&[
(Location { bank: 0, slot: 0 }, "Royal Grand 3D"),
(Location { bank: 1, slot: 2 }, "Queen Upright"),
],
)
}
fn holdings(bank: &(u32, Vec<Option<String>>)) -> (u32, usize, Vec<(usize, &str)>) {
let held = bank
.1
.iter()
.enumerate()
.filter_map(|(slot, name)| Some((slot, name.as_deref()?)))
.collect();
(bank.0, bank.1.len(), held)
}
fn scan(class: ObjectClass) -> DeviceCmd {
DeviceCmd::ScanClass {
class,
slots: crate::device::slots_per_bank(class),
banks: crate::device::MAX_BANKS,
}
}
fn scanned(events: Receiver<DeviceEvent>) -> Vec<(u32, Vec<Option<String>>)> {
events
.try_iter()
.filter_map(|event| match event {
DeviceEvent::BankScanned { bank, slots, .. } => Some((
bank,
slots
.into_iter()
.map(|slot| slot.map(|info| info.name))
.collect(),
)),
_ => None,
})
.collect()
}
fn counted(device: &Puppet, command: u32) -> usize {
device
.commands()
.into_iter()
.filter(|held| *held == command)
.count()
}
#[test]
fn a_scan_asks_only_about_the_slots_that_hold_something() {
let mut device = a_small_library();
let (flow, events) = drive(&mut device, scan(ObjectClass::Piano));
assert!(flow == Flow::Continue);
let banks = scanned(events);
assert_eq!(
banks.iter().map(holdings).collect::<Vec<_>>(),
vec![
(1, 50, vec![(0, "Royal Grand 3D")]),
(2, 30, vec![(2, "Queen Upright")]),
]
);
assert!(counted(&device, cmd::NEXT_SLOT) > 0, "the cursor was used");
assert_eq!(counted(&device, cmd::INFO), 5, "not the 80 addresses");
}
#[test]
fn a_device_that_refuses_to_enumerate_is_walked_slot_by_slot() {
let mut device = a_small_library().no_enumeration();
let (flow, events) = drive(&mut device, scan(ObjectClass::Piano));
assert!(flow == Flow::Continue, "a refusal is not a disconnection");
let banks = scanned(events);
assert_eq!(
banks.iter().map(holdings).collect::<Vec<_>>(),
vec![
(1, 50, vec![(0, "Royal Grand 3D")]),
(2, 30, vec![(2, "Queen Upright")]),
],
"the same folder, found the long way"
);
assert!(counted(&device, cmd::NEXT_SLOT) > 0, "it was tried");
assert_eq!(counted(&device, cmd::INFO), 81);
}
#[test]
fn the_devices_own_geometry_shapes_the_scan() {
let mut device = a_small_library();
let (_, events) = drive(&mut device, scan(ObjectClass::Piano));
let mut named = Vec::new();
let mut widths = Vec::new();
for event in events.try_iter() {
match event {
DeviceEvent::Geometry { banks, .. } => {
named = banks
.into_iter()
.map(|bank| (bank.name, bank.slots))
.collect()
}
DeviceEvent::BankScanned { slots, .. } => widths.push(slots.len()),
_ => {}
}
}
assert_eq!(
named,
vec![("Grand".to_string(), 50), ("Upright".to_string(), 30)],
"the categories reach the browser by name"
);
assert_eq!(widths, vec![50, 30], "and its capacities, not the guess");
}
#[test]
fn a_scan_falls_back_to_the_counters_when_the_banks_are_not_reported() {
let mut device = Puppet::stocked(
&[("Bank 1", 50)],
&[(Location { bank: 0, slot: 1 }, "Africa Split")],
)
.mute_about_geometry();
let (flow, events) = drive(&mut device, scan(ObjectClass::Program));
assert!(flow == Flow::Continue);
let banks = scanned(events);
assert_eq!(
banks.iter().map(holdings).collect::<Vec<_>>(),
vec![(1, 50, vec![(1, "Africa Split")])],
"one bank of the guessed 50, and nothing past it"
);
}
#[test]
fn an_unbounded_bank_is_not_cut_off_at_the_guess() {
let filled: Vec<(Location, &'static str)> = (0..60)
.map(|slot| (Location { bank: 0, slot }, "Marimba"))
.collect();
let mut device = Puppet::stocked(&[("Samp Lib", Bank::UNBOUNDED)], &filled);
let (flow, events) = drive(&mut device, scan(ObjectClass::Sample));
assert!(flow == Flow::Continue);
let banks = scanned(events);
assert_eq!(banks.len(), 1);
assert_eq!(banks[0].1.len(), 60, "all of them, not the 50 guessed");
assert!(banks[0].1.iter().all(Option::is_some));
}
#[test]
fn an_empty_unbounded_bank_looks_the_same_to_both_walks() {
let library = || Puppet::stocked(&[("Samp Lib", Bank::UNBOUNDED)], &[]);
let (_, by_cursor) = drive(&mut library(), scan(ObjectClass::Sample));
let (_, slot_by_slot) = drive(&mut library().no_enumeration(), scan(ObjectClass::Sample));
assert_eq!(scanned(by_cursor), scanned(slot_by_slot));
}
#[test]
fn a_full_class_is_read_slot_by_slot_and_a_sparse_one_by_cursor() {
let full: Vec<(Location, &'static str)> = (0..2)
.flat_map(|bank| (0..50).map(move |slot| (Location { bank, slot }, "Africa Split")))
.collect();
let banks = [("Bank 1", 50), ("Bank 2", 50)];
let mut dense = Puppet::stocked(&banks, &full);
drive(&mut dense, scan(ObjectClass::Program));
assert_eq!(counted(&dense, cmd::NEXT_SLOT), 0, "the cursor was skipped");
assert_eq!(
counted(&dense, cmd::INFO),
100,
"one per address, and no more"
);
let mut sparse = Puppet::stocked(&banks, &full[..2]);
drive(&mut sparse, scan(ObjectClass::Program));
assert!(counted(&sparse, cmd::NEXT_SLOT) > 0, "the cursor earned it");
assert!(counted(&sparse, cmd::INFO) < 100);
}
#[test]
fn a_preflight_that_cannot_be_made_does_not_stop_the_write() {
let mut device = Puppet::stocked(&[("Bank 1", 50)], &[]).garbling_geometry();
let (flow, _) = drive(
&mut device,
DeviceCmd::Put {
id: 1,
class: ObjectClass::Program,
at: Location { bank: 0, slot: 3 },
name: "Africa-Split.ne5p".into(),
bytes: a_program(),
},
);
assert!(flow == Flow::Continue, "not a disconnection");
assert_eq!(counted(&device, cmd::WRITE_DATA), 1, "the bytes still went");
}
#[test]
fn a_scan_with_nothing_to_go_on_stops_where_the_device_says_it_ends() {
let mut device = Puppet::stocked(
&[("Bank 1", 4)],
&[(Location { bank: 0, slot: 1 }, "Africa Split")],
)
.mute_about_geometry()
.mute_about_counters();
let (flow, events) = drive(&mut device, scan(ObjectClass::Program));
assert!(flow == Flow::Continue);
let banks = scanned(events);
assert_eq!(
banks.iter().map(holdings).collect::<Vec<_>>(),
vec![(1, 4, vec![(1, "Africa Split")])],
"the one bank there is, cut where the device refused"
);
}
#[test]
fn a_scan_reports_the_slot_the_panel_has_loaded() {
let panel = Location { bank: 1, slot: 2 };
let mut device = a_small_library().focused_on(panel);
let (_, events) = drive(&mut device, scan(ObjectClass::Piano));
let focused: Vec<Location> = events
.try_iter()
.filter_map(|event| match event {
DeviceEvent::Focus { at, .. } => at,
_ => None,
})
.collect();
assert_eq!(focused, vec![panel]);
}
#[test]
fn a_write_past_the_end_is_refused_before_anything_is_deleted() {
let mut device = a_small_library();
let (flow, events) = drive(
&mut device,
DeviceCmd::Put {
id: 1,
class: ObjectClass::Program,
at: Location { bank: 6, slot: 0 },
name: "Africa-Split.ne5p".into(),
bytes: a_program(),
},
);
assert!(flow == Flow::Continue, "it said no, it did not go away");
let refused: Vec<String> = events
.try_iter()
.filter_map(|event| match event {
DeviceEvent::OpFailed(why) => Some(why),
_ => None,
})
.collect();
let why = refused.join(" | ");
assert!(why.contains("bank 7 does not exist"), "{why}");
assert!(
why.contains("Grand, Upright"),
"in the panel's own words: {why}"
);
assert_eq!(counted(&device, cmd::DELETE), 0, "nothing was emptied");
assert_eq!(
counted(&device, cmd::BEGIN_WRITE),
0,
"and nothing was sent"
);
}
#[test]
fn a_write_to_a_real_address_still_goes() {
let mut device = Puppet::stocked(&[("Bank 1", 50)], &[]);
let (flow, _) = drive(
&mut device,
DeviceCmd::Put {
id: 1,
class: ObjectClass::Program,
at: Location { bank: 0, slot: 3 },
name: "Africa-Split.ne5p".into(),
bytes: a_program(),
},
);
assert!(flow == Flow::Continue);
assert_eq!(counted(&device, cmd::WRITE_DATA), 1, "the bytes went");
}
#[test]
fn a_refusal_is_not_a_disconnection() {
let (flow, _) = drive(
&mut Puppet::new(3),
DeviceCmd::SlotInfo {
class: ObjectClass::Program,
at: Location { bank: 30, slot: 3 },
},
);
assert!(flow == Flow::Continue);
}
}