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;
12use probes::{
13    probe_any, DMM_ACQUISITION_COMMANDS, DMM_READONLY_COMMANDS, FGEN_READONLY_COMMANDS,
14    PROBE_TIMEOUT, PSU_READONLY_COMMANDS,
15};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19#[cfg(feature = "async")]
20use crate::scpi::AsyncScpiSession;
21#[cfg(feature = "async")]
22use async_probes::probe_any_async;
23
24/// Source layer for a classification result.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ClassifySource {
27    ResourceParse,
28    VisaAttributes,
29    ModelRegistry,
30    ScpiIdn,
31    CapabilityProbe,
32    UserOverride,
33}
34
35/// A classified instrument kind with confidence.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ClassifiedKind {
38    pub kind: InstrumentKind,
39    pub confidence: u8,
40    pub source: ClassifySource,
41}
42
43/// A discovered device in the catalog.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct DiscoveredDevice {
46    pub address: ResourceAddress,
47    pub identity: DeviceIdentity,
48    pub supported_kinds: Vec<InstrumentKind>,
49    pub classification: Vec<ClassifiedKind>,
50    pub reachable: bool,
51    pub error: Option<String>,
52}
53
54impl DiscoveredDevice {
55    /// Returns a stable identity for instrument replacement workflows.
56    pub fn device_id(&self) -> DeviceId {
57        DeviceId::from_identity(&self.identity, &self.address.raw)
58    }
59}
60
61/// Classifies kinds from parsed resource address (layer 1).
62pub fn classify_from_address(
63    address: &ResourceAddress,
64    registry: &ModelRegistry,
65) -> (DeviceIdentity, Vec<ClassifiedKind>) {
66    let mut identity = DeviceIdentity::default();
67    let mut kinds = Vec::new();
68
69    if let (Some(vid), Some(pid)) = (
70        address.components.vid.as_deref(),
71        address.components.pid.as_deref(),
72    ) {
73        if let Some(hint) = registry.lookup_usb(vid, pid) {
74            identity.manufacturer = hint.manufacturer.clone();
75            identity.model = hint.model.clone();
76            for kind in &hint.kinds {
77                kinds.push(ClassifiedKind {
78                    kind: *kind,
79                    confidence: 40,
80                    source: ClassifySource::ModelRegistry,
81                });
82            }
83        }
84    }
85
86    if identity.serial.is_none() {
87        identity.serial = address.components.serial.clone();
88    }
89
90    if kinds.is_empty() {
91        kinds.push(ClassifiedKind {
92            kind: InstrumentKind::Unknown,
93            confidence: 10,
94            source: ClassifySource::ResourceParse,
95        });
96    }
97
98    (identity, kinds)
99}
100
101/// Classifies kinds from transport identity hints (layer 2).
102pub fn classify_from_transport_hint(
103    hint: &TransportIdentity,
104    registry: &ModelRegistry,
105) -> (DeviceIdentity, Vec<ClassifiedKind>) {
106    let identity = DeviceIdentity {
107        manufacturer: hint.manufacturer.clone(),
108        model: hint.model.clone(),
109        serial: hint.serial.clone(),
110        firmware: None,
111        options: None,
112    };
113    let mut kinds = Vec::new();
114
115    if let (Some(m), Some(model)) = (&hint.manufacturer, &hint.model) {
116        if let Some(registry_kinds) = registry.lookup_model(m, model) {
117            for kind in registry_kinds {
118                kinds.push(ClassifiedKind {
119                    kind,
120                    confidence: 45,
121                    source: ClassifySource::ModelRegistry,
122                });
123            }
124        }
125    }
126
127    if kinds.is_empty() && hint.manf_id.is_some() {
128        kinds.push(ClassifiedKind {
129            kind: InstrumentKind::Unknown,
130            confidence: 15,
131            source: ClassifySource::VisaAttributes,
132        });
133    }
134
135    (identity, kinds)
136}
137
138/// Classifies kinds from SCPI *IDN? (layer 4).
139pub fn classify_from_identity(
140    idn: &Idn,
141    registry: &ModelRegistry,
142) -> (DeviceIdentity, Vec<ClassifiedKind>) {
143    let identity = DeviceIdentity::from_idn(idn);
144    let mut kinds = Vec::new();
145
146    if let Some(registry_kinds) = registry.lookup_model(&idn.manufacturer, &idn.model) {
147        for kind in registry_kinds {
148            kinds.push(ClassifiedKind {
149                kind,
150                confidence: 60,
151                source: ClassifySource::ModelRegistry,
152            });
153        }
154    }
155
156    kinds.push(ClassifiedKind {
157        kind: InstrumentKind::Unknown,
158        confidence: 30,
159        source: ClassifySource::ScpiIdn,
160    });
161
162    (identity, kinds)
163}
164
165/// Capability probing (layer 5) using the given policy.
166pub fn classify_with_policy(session: &mut ScpiSession, policy: ProbePolicy) -> Vec<ClassifiedKind> {
167    match policy {
168        ProbePolicy::None => Vec::new(),
169        ProbePolicy::ReadOnly => classify_readonly_probes(session),
170        ProbePolicy::Full => {
171            let mut kinds = classify_readonly_probes(session);
172            kinds.extend(classify_acquisition_probes(session));
173            kinds
174        }
175    }
176}
177
178/// Full capability probing including acquisition-triggering queries.
179pub fn classify_deep(session: &mut ScpiSession) -> Vec<ClassifiedKind> {
180    classify_with_policy(session, ProbePolicy::Full)
181}
182
183fn classify_readonly_probes(session: &mut ScpiSession) -> Vec<ClassifiedKind> {
184    let mut kinds = Vec::new();
185
186    if probe_any(session, DMM_READONLY_COMMANDS, PROBE_TIMEOUT) {
187        kinds.push(ClassifiedKind {
188            kind: InstrumentKind::Dmm,
189            confidence: 80,
190            source: ClassifySource::CapabilityProbe,
191        });
192    }
193
194    if probe_any(session, PSU_READONLY_COMMANDS, PROBE_TIMEOUT) {
195        kinds.push(ClassifiedKind {
196            kind: InstrumentKind::DcPowerSupply,
197            confidence: 85,
198            source: ClassifySource::CapabilityProbe,
199        });
200    }
201
202    if probe_any(session, FGEN_READONLY_COMMANDS, PROBE_TIMEOUT) {
203        kinds.push(ClassifiedKind {
204            kind: InstrumentKind::FunctionGenerator,
205            confidence: 85,
206            source: ClassifySource::CapabilityProbe,
207        });
208    }
209
210    kinds
211}
212
213fn classify_acquisition_probes(session: &mut ScpiSession) -> Vec<ClassifiedKind> {
214    let mut kinds = Vec::new();
215
216    if probe_any(session, DMM_ACQUISITION_COMMANDS, PROBE_TIMEOUT) {
217        kinds.push(ClassifiedKind {
218            kind: InstrumentKind::Dmm,
219            confidence: 90,
220            source: ClassifySource::CapabilityProbe,
221        });
222    }
223
224    kinds
225}
226
227/// Async capability probing (layer 5) using the given policy.
228#[cfg(feature = "async")]
229pub async fn classify_with_policy_async(
230    session: &mut AsyncScpiSession,
231    policy: ProbePolicy,
232) -> Vec<ClassifiedKind> {
233    match policy {
234        ProbePolicy::None => Vec::new(),
235        ProbePolicy::ReadOnly => classify_readonly_probes_async(session).await,
236        ProbePolicy::Full => {
237            let mut kinds = classify_readonly_probes_async(session).await;
238            kinds.extend(classify_acquisition_probes_async(session).await);
239            kinds
240        }
241    }
242}
243
244/// Full async capability probing including acquisition-triggering queries.
245#[cfg(feature = "async")]
246pub async fn classify_deep_async(session: &mut AsyncScpiSession) -> Vec<ClassifiedKind> {
247    classify_with_policy_async(session, ProbePolicy::Full).await
248}
249
250#[cfg(feature = "async")]
251async fn classify_readonly_probes_async(session: &mut AsyncScpiSession) -> Vec<ClassifiedKind> {
252    let mut kinds = Vec::new();
253
254    if probe_any_async(session, DMM_READONLY_COMMANDS, PROBE_TIMEOUT).await {
255        kinds.push(ClassifiedKind {
256            kind: InstrumentKind::Dmm,
257            confidence: 80,
258            source: ClassifySource::CapabilityProbe,
259        });
260    }
261
262    if probe_any_async(session, PSU_READONLY_COMMANDS, PROBE_TIMEOUT).await {
263        kinds.push(ClassifiedKind {
264            kind: InstrumentKind::DcPowerSupply,
265            confidence: 85,
266            source: ClassifySource::CapabilityProbe,
267        });
268    }
269
270    if probe_any_async(session, FGEN_READONLY_COMMANDS, PROBE_TIMEOUT).await {
271        kinds.push(ClassifiedKind {
272            kind: InstrumentKind::FunctionGenerator,
273            confidence: 85,
274            source: ClassifySource::CapabilityProbe,
275        });
276    }
277
278    kinds
279}
280
281#[cfg(feature = "async")]
282async fn classify_acquisition_probes_async(session: &mut AsyncScpiSession) -> Vec<ClassifiedKind> {
283    let mut kinds = Vec::new();
284
285    if probe_any_async(session, DMM_ACQUISITION_COMMANDS, PROBE_TIMEOUT).await {
286        kinds.push(ClassifiedKind {
287            kind: InstrumentKind::Dmm,
288            confidence: 90,
289            source: ClassifySource::CapabilityProbe,
290        });
291    }
292
293    kinds
294}
295
296/// Merges classification layers into final supported kinds.
297pub fn merge_classifications(
298    layers: impl IntoIterator<Item = Vec<ClassifiedKind>>,
299    user_override: Option<&[InstrumentKind]>,
300) -> (Vec<InstrumentKind>, Vec<ClassifiedKind>) {
301    if let Some(override_kinds) = user_override {
302        let classified: Vec<_> = override_kinds
303            .iter()
304            .map(|kind| ClassifiedKind {
305                kind: *kind,
306                confidence: 100,
307                source: ClassifySource::UserOverride,
308            })
309            .collect();
310        return (override_kinds.to_vec(), classified);
311    }
312
313    let mut by_kind: HashMap<InstrumentKind, ClassifiedKind> = HashMap::new();
314    let mut all = Vec::new();
315
316    for layer in layers {
317        for entry in layer {
318            all.push(entry.clone());
319            by_kind
320                .entry(entry.kind)
321                .and_modify(|existing| {
322                    if entry.confidence > existing.confidence {
323                        *existing = entry.clone();
324                    }
325                })
326                .or_insert(entry);
327        }
328    }
329
330    let mut supported: Vec<_> = by_kind
331        .values()
332        .filter(|k| k.kind != InstrumentKind::Unknown && k.confidence >= 40)
333        .map(|k| k.kind)
334        .collect();
335    supported.sort_by_key(|k| format!("{k:?}"));
336    supported.dedup();
337
338    if supported.is_empty() {
339        supported.push(InstrumentKind::Unknown);
340    }
341
342    (supported, all)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::registry::ModelRegistry;
349
350    #[test]
351    fn registry_hint_for_usb_vid_pid() {
352        let registry = ModelRegistry::embedded();
353        let addr = ResourceAddress::parse("USB0::0x0957::0x0607::SN::INSTR").unwrap();
354        let (_, kinds) = classify_from_address(&addr, &registry);
355        assert!(kinds.iter().any(|k| k.kind == InstrumentKind::Dmm));
356    }
357
358    #[test]
359    fn probe_policy_none_skips_capability_queries() {
360        use crate::connect::ConnectOptions;
361        use crate::mock::{MockTransport, ScriptStep};
362        use crate::probe_policy::ProbePolicy;
363
364        let transport = MockTransport::from_script(vec![
365            ScriptStep::Write {
366                data: ":SENS:FUNC?\n".into(),
367            },
368            ScriptStep::Read {
369                data: "VOLT\n".into(),
370            },
371        ]);
372        let mut session = ScpiSession::new(Box::new(transport), ConnectOptions::default()).unwrap();
373        let kinds = super::classify_with_policy(&mut session, ProbePolicy::None);
374        assert!(kinds.is_empty());
375    }
376
377    #[test]
378    fn user_override_wins() {
379        let (kinds, _) = merge_classifications(
380            vec![vec![ClassifiedKind {
381                kind: InstrumentKind::Unknown,
382                confidence: 10,
383                source: ClassifySource::ResourceParse,
384            }]],
385            Some(&[InstrumentKind::Dmm]),
386        );
387        assert_eq!(kinds, vec![InstrumentKind::Dmm]);
388    }
389}