Skip to main content

instrument_core/classifier/
mod.rs

1#[cfg(feature = "async")]
2mod async_probes;
3mod probes;
4
5use crate::address::ResourceAddress;
6use crate::identity::{DeviceId, DeviceIdentity, Idn};
7use crate::kind::InstrumentKind;
8use crate::probe_policy::ProbePolicy;
9use crate::registry::ModelRegistry;
10use crate::scpi::ScpiSession;
11use crate::transport::TransportIdentity;
12pub use probes::valid_probe_reply;
13use probes::{
14    probe_any, COUNTER_READONLY_COMMANDS, DMM_ACQUISITION_COMMANDS, DMM_READONLY_COMMANDS,
15    FGEN_READONLY_COMMANDS, PROBE_TIMEOUT, PSU_READONLY_COMMANDS, PWRMETER_READONLY_COMMANDS,
16    SCOPE_READONLY_COMMANDS, SPECAN_READONLY_COMMANDS, SWITCH_READONLY_COMMANDS,
17};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21#[cfg(feature = "async")]
22use crate::scpi::AsyncScpiSession;
23#[cfg(feature = "async")]
24use async_probes::probe_any_async;
25
26/// Source layer for a classification result.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub enum ClassifySource {
29    ResourceParse,
30    VisaAttributes,
31    ModelRegistry,
32    ScpiIdn,
33    CapabilityProbe,
34    UserOverride,
35}
36
37/// A classified instrument kind with confidence.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ClassifiedKind {
40    pub kind: InstrumentKind,
41    pub confidence: u8,
42    pub source: ClassifySource,
43}
44
45/// A discovered device in the catalog.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DiscoveredDevice {
48    pub address: ResourceAddress,
49    pub identity: DeviceIdentity,
50    pub supported_kinds: Vec<InstrumentKind>,
51    pub classification: Vec<ClassifiedKind>,
52    pub reachable: bool,
53    pub error: Option<String>,
54}
55
56impl DiscoveredDevice {
57    /// Returns a stable identity for instrument replacement workflows.
58    pub fn device_id(&self) -> DeviceId {
59        DeviceId::from_identity(&self.identity, &self.address.raw)
60    }
61}
62
63/// Classifies kinds from parsed resource address (layer 1).
64pub fn classify_from_address(
65    address: &ResourceAddress,
66    registry: &ModelRegistry,
67) -> (DeviceIdentity, Vec<ClassifiedKind>) {
68    let mut identity = DeviceIdentity::default();
69    let mut kinds = Vec::new();
70
71    if let (Some(vid), Some(pid)) = (
72        address.components.vid.as_deref(),
73        address.components.pid.as_deref(),
74    ) {
75        if let Some(hint) = registry.lookup_usb(vid, pid) {
76            identity.manufacturer = hint.manufacturer.clone();
77            identity.model = hint.model.clone();
78            for kind in &hint.kinds {
79                kinds.push(ClassifiedKind {
80                    kind: *kind,
81                    confidence: 40,
82                    source: ClassifySource::ModelRegistry,
83                });
84            }
85        }
86    }
87
88    if identity.serial.is_none() {
89        identity.serial = address.components.serial.clone();
90    }
91
92    if kinds.is_empty() {
93        kinds.push(ClassifiedKind {
94            kind: InstrumentKind::Unknown,
95            confidence: 10,
96            source: ClassifySource::ResourceParse,
97        });
98    }
99
100    (identity, kinds)
101}
102
103/// Classifies kinds from transport identity hints (layer 2).
104pub fn classify_from_transport_hint(
105    hint: &TransportIdentity,
106    registry: &ModelRegistry,
107) -> (DeviceIdentity, Vec<ClassifiedKind>) {
108    let identity = DeviceIdentity {
109        manufacturer: hint.manufacturer.clone(),
110        model: hint.model.clone(),
111        serial: hint.serial.clone(),
112        firmware: None,
113        options: None,
114    };
115    let mut kinds = Vec::new();
116
117    if let (Some(m), Some(model)) = (&hint.manufacturer, &hint.model) {
118        if let Some(registry_kinds) = registry.lookup_model(m, model) {
119            for kind in registry_kinds {
120                kinds.push(ClassifiedKind {
121                    kind,
122                    confidence: 45,
123                    source: ClassifySource::ModelRegistry,
124                });
125            }
126        }
127    }
128
129    if kinds.is_empty() && hint.manf_id.is_some() {
130        kinds.push(ClassifiedKind {
131            kind: InstrumentKind::Unknown,
132            confidence: 15,
133            source: ClassifySource::VisaAttributes,
134        });
135    }
136
137    (identity, kinds)
138}
139
140/// Classifies kinds from SCPI *IDN? (layer 4).
141pub fn classify_from_identity(
142    idn: &Idn,
143    registry: &ModelRegistry,
144) -> (DeviceIdentity, Vec<ClassifiedKind>) {
145    let identity = DeviceIdentity::from_idn(idn);
146    let mut kinds = Vec::new();
147
148    if let Some(registry_kinds) = registry.lookup_model(&idn.manufacturer, &idn.model) {
149        for kind in registry_kinds {
150            kinds.push(ClassifiedKind {
151                kind,
152                confidence: 60,
153                source: ClassifySource::ModelRegistry,
154            });
155        }
156    }
157
158    kinds.push(ClassifiedKind {
159        kind: InstrumentKind::Unknown,
160        confidence: 30,
161        source: ClassifySource::ScpiIdn,
162    });
163
164    (identity, kinds)
165}
166
167/// Capability probing (layer 5) using the given policy.
168pub fn classify_with_policy(session: &mut ScpiSession, policy: ProbePolicy) -> Vec<ClassifiedKind> {
169    match policy {
170        ProbePolicy::None => Vec::new(),
171        ProbePolicy::ReadOnly => classify_readonly_probes(session),
172        ProbePolicy::Full => {
173            let mut kinds = classify_readonly_probes(session);
174            kinds.extend(classify_acquisition_probes(session));
175            kinds
176        }
177    }
178}
179
180/// Full capability probing including acquisition-triggering queries.
181pub fn classify_deep(session: &mut ScpiSession) -> Vec<ClassifiedKind> {
182    classify_with_policy(session, ProbePolicy::Full)
183}
184
185fn classify_readonly_probes(session: &mut ScpiSession) -> Vec<ClassifiedKind> {
186    let mut kinds = Vec::new();
187
188    if probe_any(session, DMM_READONLY_COMMANDS, PROBE_TIMEOUT) {
189        kinds.push(ClassifiedKind {
190            kind: InstrumentKind::Dmm,
191            confidence: 80,
192            source: ClassifySource::CapabilityProbe,
193        });
194    }
195
196    if probe_any(session, PSU_READONLY_COMMANDS, PROBE_TIMEOUT) {
197        kinds.push(ClassifiedKind {
198            kind: InstrumentKind::DcPowerSupply,
199            confidence: 85,
200            source: ClassifySource::CapabilityProbe,
201        });
202    }
203
204    if probe_any(session, FGEN_READONLY_COMMANDS, PROBE_TIMEOUT) {
205        kinds.push(ClassifiedKind {
206            kind: InstrumentKind::FunctionGenerator,
207            confidence: 85,
208            source: ClassifySource::CapabilityProbe,
209        });
210    }
211
212    if probe_any(session, SCOPE_READONLY_COMMANDS, PROBE_TIMEOUT) {
213        kinds.push(ClassifiedKind {
214            kind: InstrumentKind::Oscilloscope,
215            confidence: 85,
216            source: ClassifySource::CapabilityProbe,
217        });
218    }
219
220    if probe_any(session, SWITCH_READONLY_COMMANDS, PROBE_TIMEOUT) {
221        kinds.push(ClassifiedKind {
222            kind: InstrumentKind::Switch,
223            confidence: 85,
224            source: ClassifySource::CapabilityProbe,
225        });
226    }
227
228    if probe_any(session, COUNTER_READONLY_COMMANDS, PROBE_TIMEOUT) {
229        kinds.push(ClassifiedKind {
230            kind: InstrumentKind::Counter,
231            confidence: 85,
232            source: ClassifySource::CapabilityProbe,
233        });
234    }
235
236    if probe_any(session, PWRMETER_READONLY_COMMANDS, PROBE_TIMEOUT) {
237        kinds.push(ClassifiedKind {
238            kind: InstrumentKind::PowerMeter,
239            confidence: 85,
240            source: ClassifySource::CapabilityProbe,
241        });
242    }
243
244    if probe_any(session, SPECAN_READONLY_COMMANDS, PROBE_TIMEOUT) {
245        kinds.push(ClassifiedKind {
246            kind: InstrumentKind::SpectrumAnalyzer,
247            confidence: 85,
248            source: ClassifySource::CapabilityProbe,
249        });
250    }
251
252    kinds
253}
254
255fn classify_acquisition_probes(session: &mut ScpiSession) -> Vec<ClassifiedKind> {
256    let mut kinds = Vec::new();
257
258    if probe_any(session, DMM_ACQUISITION_COMMANDS, PROBE_TIMEOUT) {
259        kinds.push(ClassifiedKind {
260            kind: InstrumentKind::Dmm,
261            confidence: 90,
262            source: ClassifySource::CapabilityProbe,
263        });
264    }
265
266    kinds
267}
268
269/// Async capability probing (layer 5) using the given policy.
270#[cfg(feature = "async")]
271pub async fn classify_with_policy_async(
272    session: &mut AsyncScpiSession,
273    policy: ProbePolicy,
274) -> Vec<ClassifiedKind> {
275    match policy {
276        ProbePolicy::None => Vec::new(),
277        ProbePolicy::ReadOnly => classify_readonly_probes_async(session).await,
278        ProbePolicy::Full => {
279            let mut kinds = classify_readonly_probes_async(session).await;
280            kinds.extend(classify_acquisition_probes_async(session).await);
281            kinds
282        }
283    }
284}
285
286/// Full async capability probing including acquisition-triggering queries.
287#[cfg(feature = "async")]
288pub async fn classify_deep_async(session: &mut AsyncScpiSession) -> Vec<ClassifiedKind> {
289    classify_with_policy_async(session, ProbePolicy::Full).await
290}
291
292#[cfg(feature = "async")]
293async fn classify_readonly_probes_async(session: &mut AsyncScpiSession) -> Vec<ClassifiedKind> {
294    let mut kinds = Vec::new();
295
296    if probe_any_async(session, DMM_READONLY_COMMANDS, PROBE_TIMEOUT).await {
297        kinds.push(ClassifiedKind {
298            kind: InstrumentKind::Dmm,
299            confidence: 80,
300            source: ClassifySource::CapabilityProbe,
301        });
302    }
303
304    if probe_any_async(session, PSU_READONLY_COMMANDS, PROBE_TIMEOUT).await {
305        kinds.push(ClassifiedKind {
306            kind: InstrumentKind::DcPowerSupply,
307            confidence: 85,
308            source: ClassifySource::CapabilityProbe,
309        });
310    }
311
312    if probe_any_async(session, FGEN_READONLY_COMMANDS, PROBE_TIMEOUT).await {
313        kinds.push(ClassifiedKind {
314            kind: InstrumentKind::FunctionGenerator,
315            confidence: 85,
316            source: ClassifySource::CapabilityProbe,
317        });
318    }
319
320    if probe_any_async(session, SCOPE_READONLY_COMMANDS, PROBE_TIMEOUT).await {
321        kinds.push(ClassifiedKind {
322            kind: InstrumentKind::Oscilloscope,
323            confidence: 85,
324            source: ClassifySource::CapabilityProbe,
325        });
326    }
327
328    if probe_any_async(session, SWITCH_READONLY_COMMANDS, PROBE_TIMEOUT).await {
329        kinds.push(ClassifiedKind {
330            kind: InstrumentKind::Switch,
331            confidence: 85,
332            source: ClassifySource::CapabilityProbe,
333        });
334    }
335
336    if probe_any_async(session, COUNTER_READONLY_COMMANDS, PROBE_TIMEOUT).await {
337        kinds.push(ClassifiedKind {
338            kind: InstrumentKind::Counter,
339            confidence: 85,
340            source: ClassifySource::CapabilityProbe,
341        });
342    }
343
344    if probe_any_async(session, PWRMETER_READONLY_COMMANDS, PROBE_TIMEOUT).await {
345        kinds.push(ClassifiedKind {
346            kind: InstrumentKind::PowerMeter,
347            confidence: 85,
348            source: ClassifySource::CapabilityProbe,
349        });
350    }
351
352    if probe_any_async(session, SPECAN_READONLY_COMMANDS, PROBE_TIMEOUT).await {
353        kinds.push(ClassifiedKind {
354            kind: InstrumentKind::SpectrumAnalyzer,
355            confidence: 85,
356            source: ClassifySource::CapabilityProbe,
357        });
358    }
359
360    kinds
361}
362
363#[cfg(feature = "async")]
364async fn classify_acquisition_probes_async(session: &mut AsyncScpiSession) -> Vec<ClassifiedKind> {
365    let mut kinds = Vec::new();
366
367    if probe_any_async(session, DMM_ACQUISITION_COMMANDS, PROBE_TIMEOUT).await {
368        kinds.push(ClassifiedKind {
369            kind: InstrumentKind::Dmm,
370            confidence: 90,
371            source: ClassifySource::CapabilityProbe,
372        });
373    }
374
375    kinds
376}
377
378/// Merges classification layers into final supported kinds.
379pub fn merge_classifications(
380    layers: impl IntoIterator<Item = Vec<ClassifiedKind>>,
381    user_override: Option<&[InstrumentKind]>,
382) -> (Vec<InstrumentKind>, Vec<ClassifiedKind>) {
383    if let Some(override_kinds) = user_override {
384        let classified: Vec<_> = override_kinds
385            .iter()
386            .map(|kind| ClassifiedKind {
387                kind: *kind,
388                confidence: 100,
389                source: ClassifySource::UserOverride,
390            })
391            .collect();
392        return (override_kinds.to_vec(), classified);
393    }
394
395    let mut by_kind: HashMap<InstrumentKind, ClassifiedKind> = HashMap::new();
396    let mut all = Vec::new();
397
398    for layer in layers {
399        for entry in layer {
400            all.push(entry.clone());
401            by_kind
402                .entry(entry.kind)
403                .and_modify(|existing| {
404                    if entry.confidence > existing.confidence {
405                        *existing = entry.clone();
406                    }
407                })
408                .or_insert(entry);
409        }
410    }
411
412    let mut supported: Vec<_> = by_kind
413        .values()
414        .filter(|k| k.kind != InstrumentKind::Unknown && k.confidence >= 40)
415        .map(|k| k.kind)
416        .collect();
417    supported.sort_by_key(|k| format!("{k:?}"));
418    supported.dedup();
419
420    if supported.is_empty() {
421        supported.push(InstrumentKind::Unknown);
422    }
423
424    (supported, all)
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::registry::ModelRegistry;
431
432    #[test]
433    fn registry_hint_for_usb_vid_pid() {
434        let registry = ModelRegistry::embedded();
435        let addr = ResourceAddress::parse("USB0::0x0957::0x0607::SN::INSTR").unwrap();
436        let (_, kinds) = classify_from_address(&addr, &registry);
437        assert!(kinds.iter().any(|k| k.kind == InstrumentKind::Dmm));
438    }
439
440    #[test]
441    fn probe_policy_none_skips_capability_queries() {
442        use crate::connect::ConnectOptions;
443        use crate::mock::{MockTransport, ScriptStep};
444        use crate::probe_policy::ProbePolicy;
445
446        let transport = MockTransport::from_script(vec![
447            ScriptStep::Write {
448                data: ":SENS:FUNC?\n".into(),
449            },
450            ScriptStep::Read {
451                data: "VOLT\n".into(),
452            },
453        ]);
454        let mut session = ScpiSession::new(Box::new(transport), ConnectOptions::default()).unwrap();
455        let kinds = super::classify_with_policy(&mut session, ProbePolicy::None);
456        assert!(kinds.is_empty());
457    }
458
459    #[test]
460    fn user_override_wins() {
461        let (kinds, _) = merge_classifications(
462            vec![vec![ClassifiedKind {
463                kind: InstrumentKind::Unknown,
464                confidence: 10,
465                source: ClassifySource::ResourceParse,
466            }]],
467            Some(&[InstrumentKind::Dmm]),
468        );
469        assert_eq!(kinds, vec![InstrumentKind::Dmm]);
470    }
471
472    #[test]
473    fn scope_readonly_probe_succeeds() {
474        use crate::connect::ConnectOptions;
475        use crate::mock::{MockTransport, ScriptStep};
476        use probes::{probe_any, PROBE_TIMEOUT, SCOPE_READONLY_COMMANDS};
477
478        let transport = MockTransport::from_script(vec![
479            ScriptStep::Write {
480                data: ":TIMebase:SCALe?\n".into(),
481            },
482            ScriptStep::Read {
483                data: "1e-3\n".into(),
484            },
485        ]);
486        let mut session = ScpiSession::new(Box::new(transport), ConnectOptions::default()).unwrap();
487        assert!(probe_any(
488            &mut session,
489            SCOPE_READONLY_COMMANDS,
490            PROBE_TIMEOUT
491        ));
492    }
493
494    #[test]
495    fn switch_readonly_probe_succeeds() {
496        use crate::connect::ConnectOptions;
497        use crate::mock::{MockTransport, ScriptStep};
498        use probes::{probe_any, PROBE_TIMEOUT, SWITCH_READONLY_COMMANDS};
499
500        let transport = MockTransport::from_script(vec![
501            ScriptStep::Write {
502                data: ":ROUTe:CLOS?\n".into(),
503            },
504            ScriptStep::Read { data: "0\n".into() },
505        ]);
506        let mut session = ScpiSession::new(Box::new(transport), ConnectOptions::default()).unwrap();
507        assert!(probe_any(
508            &mut session,
509            SWITCH_READONLY_COMMANDS,
510            PROBE_TIMEOUT
511        ));
512    }
513
514    #[test]
515    fn counter_readonly_probe_succeeds() {
516        use crate::connect::ConnectOptions;
517        use crate::mock::{MockTransport, ScriptStep};
518        use probes::{probe_any, COUNTER_READONLY_COMMANDS, PROBE_TIMEOUT};
519
520        let transport = MockTransport::from_script(vec![
521            ScriptStep::Write {
522                data: ":COUNter:DATA?\n".into(),
523            },
524            ScriptStep::Read {
525                data: "42\n".into(),
526            },
527        ]);
528        let mut session = ScpiSession::new(Box::new(transport), ConnectOptions::default()).unwrap();
529        assert!(probe_any(
530            &mut session,
531            COUNTER_READONLY_COMMANDS,
532            PROBE_TIMEOUT
533        ));
534    }
535}