272 lines
10 KiB
Rust
272 lines
10 KiB
Rust
//! Identifying physical MIDI hardware across unplugging and replugging.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fmt;
|
|
|
|
/// How confidently a stored fingerprint matches hardware currently attached.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum MatchConfidence {
|
|
/// Nothing matched.
|
|
None,
|
|
/// Only the reported name matched, or several devices matched equally well.
|
|
///
|
|
/// Never rebinds a route on its own: binding the wrong hardware is worse than asking.
|
|
Ambiguous,
|
|
/// Manufacturer, model and name all matched, with no competing candidate.
|
|
Probable,
|
|
/// A hardware-unique value matched — a CoreMIDI unique id or a USB serial number.
|
|
Exact,
|
|
}
|
|
|
|
impl MatchConfidence {
|
|
/// Reports whether this match is strong enough to rebind a route without asking the user.
|
|
pub fn is_automatic(&self) -> bool {
|
|
matches!(self, Self::Exact | Self::Probable)
|
|
}
|
|
}
|
|
|
|
/// The set of identifying values a platform reports for one piece of MIDI hardware.
|
|
///
|
|
/// No single field is reliable on every platform, so identity is a composite. `name` is the only
|
|
/// field always present and is never sufficient on its own, because two identical devices report
|
|
/// the same one.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct DeviceFingerprint {
|
|
/// CoreMIDI's `kMIDIPropertyUniqueID`, stable across replug on macOS.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub unique_id: Option<u32>,
|
|
/// The USB serial number, when the device reports one.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub usb_serial: Option<String>,
|
|
/// The manufacturer as reported by the device.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub manufacturer: Option<String>,
|
|
/// The model as reported by the device.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
/// The display name. Always present, never sufficient alone.
|
|
pub name: String,
|
|
/// The physical connection path, stable per socket but not across sockets.
|
|
///
|
|
/// The fallback for Linux hardware that reports no serial number, and the reason such devices
|
|
/// can only ever match `Ambiguous` when moved to a different port.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub topology_path: Option<String>,
|
|
}
|
|
|
|
impl DeviceFingerprint {
|
|
/// Creates a fingerprint carrying only a name, which is all some platforms report.
|
|
pub fn from_name(name: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
..Self::default()
|
|
}
|
|
}
|
|
|
|
/// Reports whether this fingerprint carries a hardware-unique value.
|
|
pub fn has_unique_identity(&self) -> bool {
|
|
self.unique_id.is_some() || self.usb_serial.is_some()
|
|
}
|
|
|
|
/// Scores how well `candidate` matches this stored fingerprint.
|
|
///
|
|
/// Unique values are checked first and are decisive in both directions: a stored unique value
|
|
/// that disagrees with the candidate's means this is different hardware, whatever else lines
|
|
/// up.
|
|
pub fn compare(&self, candidate: &Self) -> MatchConfidence {
|
|
// A hardware-unique value settles the question outright.
|
|
if let (Some(mine), Some(theirs)) = (self.unique_id, candidate.unique_id) {
|
|
return if mine == theirs {
|
|
MatchConfidence::Exact
|
|
} else {
|
|
MatchConfidence::None
|
|
};
|
|
}
|
|
if let (Some(mine), Some(theirs)) = (&self.usb_serial, &candidate.usb_serial) {
|
|
return if mine == theirs {
|
|
MatchConfidence::Exact
|
|
} else {
|
|
MatchConfidence::None
|
|
};
|
|
}
|
|
|
|
// Without one, a full descriptive match is good enough to rebind automatically.
|
|
if self.name != candidate.name {
|
|
return MatchConfidence::None;
|
|
}
|
|
let descriptive = self.manufacturer.is_some()
|
|
&& self.model.is_some()
|
|
&& self.manufacturer == candidate.manufacturer
|
|
&& self.model == candidate.model;
|
|
if descriptive {
|
|
return MatchConfidence::Probable;
|
|
}
|
|
|
|
// The same socket is decent evidence, but only for hardware that has not been moved.
|
|
if self.topology_path.is_some() && self.topology_path == candidate.topology_path {
|
|
return MatchConfidence::Probable;
|
|
}
|
|
|
|
// Identical in everything known. There is nothing more to learn about this device, and
|
|
// nothing says it is a different one. Refusing it would add the device again on every
|
|
// enumeration, since the entry made for it would not match it either. Two such devices
|
|
// still tie, and a tie is never bound.
|
|
if self == candidate {
|
|
return MatchConfidence::Probable;
|
|
}
|
|
|
|
// Name alone. Correct often enough to offer, never enough to assume.
|
|
MatchConfidence::Ambiguous
|
|
}
|
|
|
|
/// Reports whether both were found in the same place.
|
|
///
|
|
/// Only true when both know where they are: two devices that report no position are not in
|
|
/// the same one, they are simply unplaced.
|
|
fn same_position(&self, candidate: &Self) -> bool {
|
|
self.topology_path.is_some() && self.topology_path == candidate.topology_path
|
|
}
|
|
|
|
/// Picks the best match for this fingerprint among `candidates`.
|
|
///
|
|
/// Returns `Ambiguous` when several candidates tie at the best score, because choosing between
|
|
/// two identical devices is the user's call.
|
|
pub fn best_match<'a>(&self, candidates: &'a [Self]) -> (Option<&'a Self>, MatchConfidence) {
|
|
let mut best: Option<&Self> = None;
|
|
let mut best_score = MatchConfidence::None;
|
|
let mut tied = false;
|
|
|
|
for candidate in candidates {
|
|
let score = self.compare(candidate);
|
|
if score == MatchConfidence::None {
|
|
continue;
|
|
}
|
|
if score > best_score {
|
|
best_score = score;
|
|
best = Some(candidate);
|
|
tied = false;
|
|
} else if score == best_score {
|
|
// Where it is plugged in breaks a tie that nothing else can. Two identical
|
|
// devices score the same on every descriptive field, so without this they are
|
|
// ambiguous even when one of them is in the very socket this entry remembers.
|
|
match (
|
|
self.same_position(candidate),
|
|
best.is_some_and(|b| self.same_position(b)),
|
|
) {
|
|
(true, false) => {
|
|
best = Some(candidate);
|
|
tied = false;
|
|
}
|
|
(false, true) => {}
|
|
_ => tied = true,
|
|
}
|
|
}
|
|
}
|
|
|
|
match (best, tied) {
|
|
(None, _) => (None, MatchConfidence::None),
|
|
(Some(found), true) => (Some(found), MatchConfidence::Ambiguous),
|
|
(Some(found), false) => (Some(found), best_score),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for DeviceFingerprint {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}", self.name)?;
|
|
if let Some(serial) = &self.usb_serial {
|
|
write!(f, " (serial {serial})")?;
|
|
} else if let Some(id) = self.unique_id {
|
|
write!(f, " (id {id})")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn keyboard() -> DeviceFingerprint {
|
|
DeviceFingerprint {
|
|
unique_id: None,
|
|
usb_serial: Some("SN-001".to_owned()),
|
|
manufacturer: Some("Acme".to_owned()),
|
|
model: Some("K61".to_owned()),
|
|
name: "Acme K61".to_owned(),
|
|
topology_path: Some("usb-0:1.2".to_owned()),
|
|
}
|
|
}
|
|
|
|
/// Attached hardware is matched to a stored entry by the strongest evidence available, and
|
|
/// two identical devices tie rather than one being picked.
|
|
///
|
|
/// A serial number is decisive both ways: it follows the device to another socket (FR-015e)
|
|
/// and a different one is different hardware whatever else agrees. Manufacturer, model and
|
|
/// name without a serial are enough to rebind. A device reporting only a name matches itself,
|
|
/// or the entry made for it would never match and it would be added again on every
|
|
/// enumeration, but two such devices attached at once are ambiguous (FR-015g), because
|
|
/// binding the wrong one is worse than asking.
|
|
#[test]
|
|
fn hardware_is_matched_by_its_strongest_evidence() {
|
|
let moved = DeviceFingerprint {
|
|
topology_path: Some("usb-0:4.1".to_owned()),
|
|
..keyboard()
|
|
};
|
|
let other_serial = DeviceFingerprint {
|
|
usb_serial: Some("SN-002".to_owned()),
|
|
..keyboard()
|
|
};
|
|
let no_serial = DeviceFingerprint {
|
|
usb_serial: None,
|
|
..keyboard()
|
|
};
|
|
let named = DeviceFingerprint::from_name("Keystation");
|
|
let cases = [
|
|
(
|
|
"a serial matches across a different socket",
|
|
keyboard(),
|
|
vec![moved],
|
|
MatchConfidence::Exact,
|
|
),
|
|
(
|
|
"a differing serial is different hardware",
|
|
keyboard(),
|
|
vec![other_serial],
|
|
MatchConfidence::None,
|
|
),
|
|
(
|
|
"manufacturer, model and name match without a serial",
|
|
no_serial.clone(),
|
|
vec![no_serial],
|
|
MatchConfidence::Probable,
|
|
),
|
|
(
|
|
"a different name never matches",
|
|
named.clone(),
|
|
vec![DeviceFingerprint::from_name("Other Thing")],
|
|
MatchConfidence::None,
|
|
),
|
|
(
|
|
"a device known only by name matches itself",
|
|
named.clone(),
|
|
vec![named.clone()],
|
|
MatchConfidence::Probable,
|
|
),
|
|
(
|
|
"two identical devices known only by name tie",
|
|
named.clone(),
|
|
vec![named.clone(), named],
|
|
MatchConfidence::Ambiguous,
|
|
),
|
|
];
|
|
for (case, stored, attached, want) in cases {
|
|
assert_eq!(
|
|
stored.best_match(&attached).1,
|
|
want,
|
|
"{case}: the match must be as strong as the evidence and no stronger"
|
|
);
|
|
}
|
|
}
|
|
}
|