From 7d231226b6c3c56d5cf81824146c8ba1fe90afd2 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 15:24:05 +0000 Subject: [PATCH 1/4] feat(#760): Wire SysExIdentity into PortResolver (#752) Extend PortInfo with sysex_identity and update PortResolver to check matches_with_sysex() alongside matches_with_usb(). SysExIdentity matchers now resolve ports when identity data is provided. - Add sysex_identity: Option to PortInfo - Resolver chains: matches_with_usb() || matches_with_sysex() - Constructors (new, new_with_usb) default sysex_identity to None - TDD: 2 new tests (SysEx match, no-match without metadata) 16 resolver tests pass (14 existing + 2 new). Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/resolver.rs | 12 +++++- conductor-core/tests/resolver_test.rs | 62 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/conductor-core/src/resolver.rs b/conductor-core/src/resolver.rs index f2854be5..dd2e9367 100644 --- a/conductor-core/src/resolver.rs +++ b/conductor-core/src/resolver.rs @@ -6,6 +6,7 @@ //! Pure logic: `(Vec, Vec) → Vec` use crate::config::DeviceIdentityConfig; +use crate::device_intelligence::sysex_identity::SysExIdentity; use crate::identity::DeviceId; /// Information about an available MIDI port. @@ -17,16 +18,19 @@ pub struct PortInfo { pub vendor_id: Option, /// USB Product ID (populated when available from platform APIs) pub product_id: Option, + /// SysEx Identity Reply data (populated by probing, when available) + pub sysex_identity: Option, } impl PortInfo { - /// Construct a PortInfo when USB IDs are unknown or unavailable. + /// Construct a PortInfo when no metadata is available. pub fn new(name: impl Into, index: usize) -> Self { Self { name: name.into(), index, vendor_id: None, product_id: None, + sysex_identity: None, } } @@ -42,6 +46,7 @@ impl PortInfo { index, vendor_id: Some(vendor_id), product_id: Some(product_id), + sysex_identity: None, } } } @@ -92,7 +97,10 @@ impl PortResolver { } // Check all matchers on this identity; take highest specificity match for matcher in &identity.matchers { - if matcher.matches_with_usb(&port.name, port.vendor_id, port.product_id) { + let matched = + matcher.matches_with_usb(&port.name, port.vendor_id, port.product_id) + || matcher.matches_with_sysex(&port.name, port.sysex_identity.as_ref()); + if matched { let specificity = matcher.specificity(); if best_match .as_ref() diff --git a/conductor-core/tests/resolver_test.rs b/conductor-core/tests/resolver_test.rs index c767a564..43fc00d0 100644 --- a/conductor-core/tests/resolver_test.rs +++ b/conductor-core/tests/resolver_test.rs @@ -349,3 +349,65 @@ fn test_name_matcher_still_works_with_usb_fields() { matches!(&results[0], BindingResult::Bound { device_id, .. } if device_id.as_str() == "pads") ); } + +// #752: SysExIdentity matcher works in PortResolver when identity data provided +#[test] +fn test_sysex_identity_matches_with_metadata() { + use conductor_core::device_intelligence::sysex_identity::SysExIdentity; + + let ports = vec![PortInfo { + name: "Generic MIDI Port".to_string(), + index: 0, + vendor_id: None, + product_id: None, + sysex_identity: Some(SysExIdentity { + manufacturer_id: vec![0x42], // KORG + family: 0x0034, + model: 0x0001, + version: [1, 0, 0, 0], + }), + }]; + let identities = vec![DeviceIdentityConfig { + alias: "korg".to_string(), + matchers: vec![DeviceMatcher::SysExIdentity { + manufacturer_id: vec![0x42], + family: Some(0x0034), + model: None, + }], + description: None, + enabled: true, + input: None, + output: None, + protocol: None, + channels: vec![], + }]; + + let results = PortResolver::resolve(&ports, &identities); + assert_eq!(results.len(), 1); + assert!( + matches!(&results[0], BindingResult::Bound { device_id, .. } if device_id.as_str() == "korg") + ); +} + +#[test] +fn test_sysex_identity_no_match_without_metadata() { + let ports = vec![PortInfo::new("Generic MIDI Port", 0)]; + let identities = vec![DeviceIdentityConfig { + alias: "korg".to_string(), + matchers: vec![DeviceMatcher::SysExIdentity { + manufacturer_id: vec![0x42], + family: None, + model: None, + }], + description: None, + enabled: true, + input: None, + output: None, + protocol: None, + channels: vec![], + }]; + + let results = PortResolver::resolve(&ports, &identities); + assert_eq!(results.len(), 1); + assert!(matches!(&results[0], BindingResult::Unbound { .. })); +} From 7654c6bbc339f12cc9ec3517193129d61fabc58a Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 15:31:16 +0000 Subject: [PATCH 2/4] fix: Update matches_with_sysex doc, add PortInfo constructor guidance - Doc: matches_with_sysex() is now called by PortResolver (not stale) - Add doc note recommending constructors over struct literals for PortInfo Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/identity.rs | 6 +++--- conductor-core/src/resolver.rs | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/conductor-core/src/identity.rs b/conductor-core/src/identity.rs index 974ef0d3..9054c167 100644 --- a/conductor-core/src/identity.rs +++ b/conductor-core/src/identity.rs @@ -206,9 +206,9 @@ impl DeviceMatcher { /// against the provided identity. Returns false if identity is absent. /// For all other matchers, delegates to `matches(port_name)`. /// - /// Note: `PortResolver::resolve()` calls `matches_with_usb()` (not this method). - /// SysEx Identity metadata is not yet passed to the resolver. `SysExIdentity` - /// matchers will not affect binding resolution until the resolver is updated. + /// Note: `PortResolver::resolve()` calls both `matches_with_usb()` and this method, + /// passing `port.sysex_identity` from `PortInfo`. SysExIdentity matchers are active + /// in binding resolution when identity data is available. pub fn matches_with_sysex( &self, port_name: &str, diff --git a/conductor-core/src/resolver.rs b/conductor-core/src/resolver.rs index dd2e9367..a16b5ce6 100644 --- a/conductor-core/src/resolver.rs +++ b/conductor-core/src/resolver.rs @@ -10,6 +10,9 @@ use crate::device_intelligence::sysex_identity::SysExIdentity; use crate::identity::DeviceId; /// Information about an available MIDI port. +/// +/// Prefer constructors (`PortInfo::new()`, `new_with_usb()`) over struct literals +/// to avoid breakage when new metadata fields are added. #[derive(Debug, Clone)] pub struct PortInfo { pub name: String, From df0b1ac9a1432dc47d52d1632e3517e83b33b8a2 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 16:15:07 +0000 Subject: [PATCH 3/4] fix: Avoid double name check, use constructor in SysEx test (round 2) - Only call matches_with_sysex() for SysExIdentity matchers; other matchers use matches_with_usb() which already handles name matching - Use PortInfo::new() + field set instead of struct literal in test Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/resolver.rs | 12 ++++++++++-- conductor-core/tests/resolver_test.rs | 20 ++++++++------------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/conductor-core/src/resolver.rs b/conductor-core/src/resolver.rs index a16b5ce6..d536bce6 100644 --- a/conductor-core/src/resolver.rs +++ b/conductor-core/src/resolver.rs @@ -100,9 +100,17 @@ impl PortResolver { } // Check all matchers on this identity; take highest specificity match for matcher in &identity.matchers { - let matched = + // Use the appropriate matching method based on matcher type + // to avoid redundant name checks (matches_with_usb handles + // name-based and USB matchers; matches_with_sysex only for SysEx) + let matched = if matches!( + matcher, + crate::identity::DeviceMatcher::SysExIdentity { .. } + ) { + matcher.matches_with_sysex(&port.name, port.sysex_identity.as_ref()) + } else { matcher.matches_with_usb(&port.name, port.vendor_id, port.product_id) - || matcher.matches_with_sysex(&port.name, port.sysex_identity.as_ref()); + }; if matched { let specificity = matcher.specificity(); if best_match diff --git a/conductor-core/tests/resolver_test.rs b/conductor-core/tests/resolver_test.rs index 43fc00d0..1dc3d969 100644 --- a/conductor-core/tests/resolver_test.rs +++ b/conductor-core/tests/resolver_test.rs @@ -355,18 +355,14 @@ fn test_name_matcher_still_works_with_usb_fields() { fn test_sysex_identity_matches_with_metadata() { use conductor_core::device_intelligence::sysex_identity::SysExIdentity; - let ports = vec![PortInfo { - name: "Generic MIDI Port".to_string(), - index: 0, - vendor_id: None, - product_id: None, - sysex_identity: Some(SysExIdentity { - manufacturer_id: vec![0x42], // KORG - family: 0x0034, - model: 0x0001, - version: [1, 0, 0, 0], - }), - }]; + let mut port = PortInfo::new("Generic MIDI Port", 0); + port.sysex_identity = Some(SysExIdentity { + manufacturer_id: vec![0x42], // KORG + family: 0x0034, + model: 0x0001, + version: [1, 0, 0, 0], + }); + let ports = vec![port]; let identities = vec![DeviceIdentityConfig { alias: "korg".to_string(), matchers: vec![DeviceMatcher::SysExIdentity { From 9df590ac3b8b4b5fcb70e11e19f3fd74bdba6e5d Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 16:35:59 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20Clarify=20doc=20=E2=80=94=20resolver?= =?UTF-8?q?=20dispatches=20by=20matcher=20type,=20not=20calls=20both=20(ro?= =?UTF-8?q?und=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/identity.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/conductor-core/src/identity.rs b/conductor-core/src/identity.rs index 9054c167..91586a1d 100644 --- a/conductor-core/src/identity.rs +++ b/conductor-core/src/identity.rs @@ -206,9 +206,9 @@ impl DeviceMatcher { /// against the provided identity. Returns false if identity is absent. /// For all other matchers, delegates to `matches(port_name)`. /// - /// Note: `PortResolver::resolve()` calls both `matches_with_usb()` and this method, - /// passing `port.sysex_identity` from `PortInfo`. SysExIdentity matchers are active - /// in binding resolution when identity data is available. + /// Note: `PortResolver::resolve()` dispatches by matcher type: `SysExIdentity` + /// matchers use this method with `port.sysex_identity` from `PortInfo`; all other + /// matchers use `matches_with_usb()`. Active in resolution when identity data exists. pub fn matches_with_sysex( &self, port_name: &str,