Skip to main content

bambu_rs/core/
capability.rs

1//! Firmware capability / quirk registry.
2//!
3//! API and hardware behaviour vary by `(model, firmware)`. [`resolve`] looks a
4//! model and firmware up in a [`CapabilityRegistry`] and returns the known
5//! [`Capabilities`]; unknown facts are simply `None`.
6//!
7//! ## Two separated concerns
8//!
9//! - **Descriptive** capabilities (push mode, camera transport, hardware
10//!   features) describe how to *talk to and interpret* a printer. We register
11//!   these even for models we've only documented, so discovery/status work.
12//! - The **control boundary** (may we send control commands, and under what
13//!   firmware gating) is a *safety* decision. It is granted only for models we
14//!   are confident enough about; an unknown model, a firmware newer than we
15//!   know, or a model without a confirmed control boundary all refuse control
16//!   (see [`Capabilities::control_permission`]).
17//!
18//! Provenance ([`EvidenceGrade`]) travels per *model profile* as audit
19//! metadata; it is deliberately **not** consulted by `control_permission`
20//! (per-fact provenance was rejected as over-engineering — safety lives in the
21//! control boundary, not in a confidence tag).
22
23use crate::core::firmware::FirmwareVersion;
24use crate::core::model::Model;
25
26/// Whether the printer pushes its full state each time (X1 class) or only deltas
27/// that the client must cache and merge (P1/A1 class).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum PushMode {
30    Full,
31    DeltaOnly,
32}
33
34/// How the camera is reached, which differs sharply by model.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CameraTransport {
37    /// RTSP over TLS on port 322 (X1 / X1E / H2D).
38    Rtsp322,
39    /// Proprietary JPEG stream over TCP port 6000 (P1 / A1).
40    JpegTcp6000,
41    /// No LAN camera access.
42    None,
43}
44
45/// How a model's `chamber_temper` report field should be interpreted.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ChamberTemperature {
48    /// A real chamber sensor (enclosed X1 / X1E / H2D).
49    RealSensor,
50    /// The field is emitted but is **not** a real sensor (A1 / P1) — a typed
51    /// status must not surface it as a temperature.
52    ReportedSynthetic,
53    /// Not present at all.
54    Unsupported,
55}
56
57/// Hardware features that change how reports are interpreted.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct HardwareFeatures {
60    /// Micro-LiDAR (X1 / X1C / X1E) — gates `0x0C` (XCAM) HMS codes and the
61    /// lidar calibration bit.
62    pub lidar: bool,
63    pub chamber_temperature: ChamberTemperature,
64    pub aux_fan: bool,
65    pub chamber_fan: bool,
66}
67
68/// Whether the printer's "Developer Mode" (LAN-only control) is available at the
69/// queried firmware.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DeveloperMode {
72    Available,
73    Unavailable,
74}
75
76/// Whether the Authorization Control System gates third-party control commands.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum AcsPolicy {
79    /// Control requires LAN-only + Developer Mode.
80    Required,
81    /// Control is not gated.
82    NotRequired,
83}
84
85/// How well-evidenced a model profile is. Audit metadata only — **never** used
86/// to decide control.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum EvidenceGrade {
89    /// Confirmed on our own hardware.
90    Observed,
91    /// Bambu's own slicer machine list (vendor-canonical).
92    VendorSpec,
93    /// OpenBambuAPI protocol documentation.
94    DocSpec,
95    /// Extrapolated; never observed on the wire.
96    Inferred,
97}
98
99/// How well the registry matched a `(model, firmware)` query.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum RegistryStatus {
102    /// Model and firmware fall within known, supported territory.
103    Supported,
104    /// Model is known but firmware is newer than anything we have data for.
105    FirmwareNewerThanKnown,
106    /// Model is not in the registry.
107    UnknownModel,
108}
109
110/// Permission to send control commands (print start, heat, move, …).
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum ControlPermit {
113    /// Control is not gated by ACS.
114    Allowed,
115    /// Allowed, but the printer must be in LAN-only + Developer Mode.
116    RequiresDeveloperMode,
117}
118
119/// Why control was refused — distinct reasons so a caller (CLI) can map them to
120/// exit codes and actionable messages.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum ControlRefusal {
123    /// We don't recognise this model at all.
124    UnknownModel,
125    /// Firmware is newer than the registry knows; ACS behaviour may have
126    /// changed, so we refuse rather than risk an uncatalogued change.
127    FirmwareNewerThanKnown,
128    /// Developer Mode is required but not available on this firmware.
129    DeveloperModeUnavailable,
130    /// We don't have a confirmed control boundary for this model.
131    UnknownControlBoundary,
132}
133
134/// Resolved capabilities for a `(model, firmware)`. Unknown facts are `None`.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Capabilities {
137    pub model: Model,
138    pub push_mode: Option<PushMode>,
139    pub camera_transport: Option<CameraTransport>,
140    pub hardware: Option<HardwareFeatures>,
141    pub developer_mode: Option<DeveloperMode>,
142    pub acs_policy: Option<AcsPolicy>,
143    pub evidence: Option<EvidenceGrade>,
144    pub registry_status: RegistryStatus,
145}
146
147impl Capabilities {
148    /// Whether — and under what condition — control commands may be sent.
149    ///
150    /// Safety is decided from registry-match quality and the control boundary,
151    /// never from [`EvidenceGrade`]: an unknown model, firmware newer than
152    /// known, or a model without a confirmed control boundary all refuse.
153    pub fn control_permission(&self) -> Result<ControlPermit, ControlRefusal> {
154        match self.registry_status {
155            RegistryStatus::UnknownModel => return Err(ControlRefusal::UnknownModel),
156            RegistryStatus::FirmwareNewerThanKnown => {
157                return Err(ControlRefusal::FirmwareNewerThanKnown);
158            }
159            RegistryStatus::Supported => {}
160        }
161        match (self.acs_policy, self.developer_mode) {
162            (Some(AcsPolicy::NotRequired), _) => Ok(ControlPermit::Allowed),
163            (Some(AcsPolicy::Required), Some(DeveloperMode::Available)) => {
164                Ok(ControlPermit::RequiresDeveloperMode)
165            }
166            (Some(AcsPolicy::Required), Some(DeveloperMode::Unavailable)) => {
167                Err(ControlRefusal::DeveloperModeUnavailable)
168            }
169            // No confirmed control boundary (developer_mode/acs are None).
170            _ => Err(ControlRefusal::UnknownControlBoundary),
171        }
172    }
173
174    /// Convenience: whether control is permitted at all.
175    pub fn control_allowed(&self) -> bool {
176        self.control_permission().is_ok()
177    }
178
179    /// A **soft, agent-facing** assessment of whether control is expected to
180    /// work — the *degrade-not-wall* counterpart to [`control_permission`].
181    ///
182    /// The one difference: a firmware merely **newer than the registry knows**
183    /// degrades to [`ControlAssessment::NewerFirmwareUntested`] (a warning) here
184    /// instead of a hard refusal, so a routine firmware update doesn't wall off
185    /// an otherwise-working CLI — the LAN command set is very stable across
186    /// point releases. Genuinely unknown models, a missing control boundary, and
187    /// Developer Mode being unavailable still refuse, exactly as in
188    /// [`control_permission`].
189    pub fn control_assessment(&self) -> ControlAssessment {
190        match self.registry_status {
191            RegistryStatus::FirmwareNewerThanKnown => ControlAssessment::NewerFirmwareUntested,
192            RegistryStatus::UnknownModel => {
193                ControlAssessment::Refused(ControlRefusal::UnknownModel)
194            }
195            RegistryStatus::Supported => match self.control_permission() {
196                Ok(ControlPermit::Allowed) => ControlAssessment::Allowed,
197                Ok(ControlPermit::RequiresDeveloperMode) => {
198                    ControlAssessment::RequiresDeveloperMode
199                }
200                Err(refusal) => ControlAssessment::Refused(refusal),
201            },
202        }
203    }
204}
205
206/// A soft, agent-facing verdict on whether control is expected to work. Unlike
207/// [`Capabilities::control_permission`], firmware newer than known is a warning,
208/// not a refusal (see [`Capabilities::control_assessment`]).
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum ControlAssessment {
211    /// Control is permitted (ACS not enforced).
212    Allowed,
213    /// Permitted, provided the printer is in LAN-only + Developer Mode.
214    RequiresDeveloperMode,
215    /// Firmware is newer than the registry knows; control is very likely fine —
216    /// proceed, but warn that it's untested against this firmware.
217    NewerFirmwareUntested,
218    /// Control is refused for a hard reason (unknown model, no control boundary,
219    /// or Developer Mode unavailable).
220    Refused(ControlRefusal),
221}
222
223/// The confirmed control gating for a model. Present only for models we are
224/// confident enough about to permit control.
225#[derive(Debug, Clone, PartialEq, Eq)]
226struct ControlBoundary {
227    /// Firmware at/after which Developer Mode exists (and ACS gates control).
228    developer_mode_since: FirmwareVersion,
229    acs_policy: AcsPolicy,
230}
231
232/// Per-model data the registry holds.
233struct ModelProfile {
234    model: Model,
235    // Descriptive (registered even for less-certain models):
236    push_mode: PushMode,
237    camera_transport: CameraTransport,
238    hardware: HardwareFeatures,
239    evidence: EvidenceGrade,
240    /// Highest firmware we understand; newer firmware refuses control.
241    max_known_firmware: FirmwareVersion,
242    // Safety:
243    /// `None` => no confirmed control boundary => control is refused.
244    control_boundary: Option<ControlBoundary>,
245}
246
247/// A set of known model profiles. Construct via [`default_registry`] or build a
248/// custom one in tests — there is intentionally no global singleton.
249pub struct CapabilityRegistry {
250    profiles: Vec<ModelProfile>,
251}
252
253impl CapabilityRegistry {
254    fn profile(&self, model: &Model) -> Option<&ModelProfile> {
255        self.profiles.iter().find(|p| &p.model == model)
256    }
257}
258
259/// Resolve capabilities for a `(model, firmware)`. A pure function over an
260/// explicit registry — no globals, no I/O — so it is trivially testable.
261pub fn resolve(
262    registry: &CapabilityRegistry,
263    model: &Model,
264    firmware: &FirmwareVersion,
265) -> Capabilities {
266    let Some(profile) = registry.profile(model) else {
267        return Capabilities {
268            model: model.clone(),
269            push_mode: None,
270            camera_transport: None,
271            hardware: None,
272            developer_mode: None,
273            acs_policy: None,
274            evidence: None,
275            registry_status: RegistryStatus::UnknownModel,
276        };
277    };
278
279    let registry_status = if firmware > &profile.max_known_firmware {
280        RegistryStatus::FirmwareNewerThanKnown
281    } else {
282        RegistryStatus::Supported
283    };
284
285    let (developer_mode, acs_policy) = match &profile.control_boundary {
286        Some(cb) => {
287            let dev = if firmware >= &cb.developer_mode_since {
288                DeveloperMode::Available
289            } else {
290                DeveloperMode::Unavailable
291            };
292            (Some(dev), Some(cb.acs_policy))
293        }
294        None => (None, None),
295    };
296
297    Capabilities {
298        model: model.clone(),
299        push_mode: Some(profile.push_mode),
300        camera_transport: Some(profile.camera_transport),
301        hardware: Some(profile.hardware),
302        developer_mode,
303        acs_policy,
304        evidence: Some(profile.evidence),
305        registry_status,
306    }
307}
308
309/// The built-in registry of known models.
310///
311/// The A1 mini entry is **hardware-observed** (model code, firmware
312/// `01.07.02.00` and AMS-Lite confirmed on a real unit). Other entries are
313/// vendor-canonical (`EvidenceGrade::VendorSpec`) or, for the newest gear,
314/// inferred — and the inferred ones deliberately carry **no control boundary**.
315pub fn default_registry() -> CapabilityRegistry {
316    let fw = |s: &str| FirmwareVersion::parse(s).expect("valid firmware literal");
317
318    let a1_family_hw = HardwareFeatures {
319        lidar: false,
320        chamber_temperature: ChamberTemperature::ReportedSynthetic, // emitted but inert
321        aux_fan: false,
322        chamber_fan: false,
323    };
324    let p1_hw = HardwareFeatures {
325        lidar: false,
326        chamber_temperature: ChamberTemperature::ReportedSynthetic,
327        aux_fan: true,
328        chamber_fan: true,
329    };
330    let x1_hw = HardwareFeatures {
331        lidar: true,
332        chamber_temperature: ChamberTemperature::RealSensor,
333        aux_fan: true,
334        chamber_fan: true,
335    };
336
337    let a1_boundary = || {
338        Some(ControlBoundary {
339            developer_mode_since: fw("01.05.00"),
340            acs_policy: AcsPolicy::Required,
341        })
342    };
343    let p1_boundary = || {
344        Some(ControlBoundary {
345            developer_mode_since: fw("01.08.02"),
346            acs_policy: AcsPolicy::Required,
347        })
348    };
349    let x1_boundary = || {
350        Some(ControlBoundary {
351            developer_mode_since: fw("01.08.03"),
352            acs_policy: AcsPolicy::Required,
353        })
354    };
355
356    CapabilityRegistry {
357        profiles: vec![
358            // OBSERVED: our real A1 mini (firmware 01.07.02.00, hw_ver AP05, AMS Lite).
359            ModelProfile {
360                model: Model::A1Mini,
361                push_mode: PushMode::DeltaOnly,
362                camera_transport: CameraTransport::JpegTcp6000,
363                hardware: a1_family_hw,
364                evidence: EvidenceGrade::Observed,
365                max_known_firmware: fw("01.07.02"),
366                control_boundary: a1_boundary(),
367            },
368            // VendorSpec: A1 (full) — same family as the observed A1 mini.
369            ModelProfile {
370                model: Model::A1,
371                push_mode: PushMode::DeltaOnly,
372                camera_transport: CameraTransport::JpegTcp6000,
373                hardware: a1_family_hw,
374                evidence: EvidenceGrade::VendorSpec,
375                max_known_firmware: fw("01.07.02"),
376                control_boundary: a1_boundary(),
377            },
378            ModelProfile {
379                model: Model::P1P,
380                push_mode: PushMode::DeltaOnly,
381                camera_transport: CameraTransport::JpegTcp6000,
382                hardware: p1_hw,
383                evidence: EvidenceGrade::VendorSpec,
384                max_known_firmware: fw("01.08.04"),
385                control_boundary: p1_boundary(),
386            },
387            ModelProfile {
388                model: Model::P1S,
389                push_mode: PushMode::DeltaOnly,
390                camera_transport: CameraTransport::JpegTcp6000,
391                hardware: p1_hw,
392                evidence: EvidenceGrade::VendorSpec,
393                max_known_firmware: fw("01.08.04"),
394                control_boundary: p1_boundary(),
395            },
396            ModelProfile {
397                model: Model::X1Carbon,
398                push_mode: PushMode::Full,
399                camera_transport: CameraTransport::Rtsp322,
400                hardware: x1_hw,
401                evidence: EvidenceGrade::VendorSpec,
402                max_known_firmware: fw("01.08.05"),
403                control_boundary: x1_boundary(),
404            },
405            ModelProfile {
406                model: Model::X1E,
407                push_mode: PushMode::Full,
408                camera_transport: CameraTransport::Rtsp322,
409                hardware: x1_hw,
410                evidence: EvidenceGrade::VendorSpec,
411                max_known_firmware: fw("01.08.05"),
412                control_boundary: x1_boundary(),
413            },
414            // INFERRED: H2D SSDP code never observed, push mode inferred — keep
415            // descriptive info but grant NO control boundary (control refused).
416            ModelProfile {
417                model: Model::H2D,
418                push_mode: PushMode::Full,
419                camera_transport: CameraTransport::Rtsp322,
420                hardware: HardwareFeatures {
421                    lidar: false,
422                    chamber_temperature: ChamberTemperature::RealSensor,
423                    aux_fan: true,
424                    chamber_fan: true,
425                },
426                evidence: EvidenceGrade::Inferred,
427                max_known_firmware: fw("01.02.00"),
428                control_boundary: None,
429            },
430        ],
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn fw(s: &str) -> FirmwareVersion {
439        FirmwareVersion::parse(s).unwrap()
440    }
441
442    #[test]
443    fn observed_a1mini_descriptive_facts() {
444        let reg = default_registry();
445        let caps = resolve(&reg, &Model::A1Mini, &fw("01.07.02"));
446        assert_eq!(caps.push_mode, Some(PushMode::DeltaOnly));
447        assert_eq!(caps.camera_transport, Some(CameraTransport::JpegTcp6000));
448        assert_eq!(caps.evidence, Some(EvidenceGrade::Observed));
449        assert_eq!(caps.registry_status, RegistryStatus::Supported);
450        let hw = caps.hardware.unwrap();
451        assert!(!hw.lidar);
452        assert_eq!(
453            hw.chamber_temperature,
454            ChamberTemperature::ReportedSynthetic
455        );
456        assert!(!hw.aux_fan && !hw.chamber_fan);
457    }
458
459    #[test]
460    fn a1mini_real_firmware_is_supported_not_newer_than_known() {
461        // Regression: max_known_firmware must cover the real device (01.07.02.00),
462        // otherwise control would be wrongly refused as FirmwareNewerThanKnown.
463        let reg = default_registry();
464        let caps = resolve(&reg, &Model::A1Mini, &fw("01.07.02.00"));
465        assert_eq!(caps.registry_status, RegistryStatus::Supported);
466        assert_eq!(
467            caps.control_permission(),
468            Ok(ControlPermit::RequiresDeveloperMode)
469        );
470    }
471
472    #[test]
473    fn developer_mode_threshold_and_control_below_it() {
474        let reg = default_registry();
475        let below = resolve(&reg, &Model::A1Mini, &fw("01.04.99"));
476        assert_eq!(below.developer_mode, Some(DeveloperMode::Unavailable));
477        assert_eq!(
478            below.control_permission(),
479            Err(ControlRefusal::DeveloperModeUnavailable)
480        );
481    }
482
483    #[test]
484    fn unknown_model_refuses_control() {
485        let reg = default_registry();
486        let caps = resolve(&reg, &Model::Unknown("z9".into()), &fw("01.00.00"));
487        assert_eq!(caps.registry_status, RegistryStatus::UnknownModel);
488        assert_eq!(caps.push_mode, None);
489        assert_eq!(caps.control_permission(), Err(ControlRefusal::UnknownModel));
490    }
491
492    #[test]
493    fn firmware_newer_than_known_refuses_control() {
494        let reg = default_registry();
495        let caps = resolve(&reg, &Model::A1Mini, &fw("01.99.00"));
496        assert_eq!(caps.registry_status, RegistryStatus::FirmwareNewerThanKnown);
497        assert_eq!(
498            caps.control_permission(),
499            Err(ControlRefusal::FirmwareNewerThanKnown)
500        );
501        // Descriptive facts still resolve.
502        assert_eq!(caps.push_mode, Some(PushMode::DeltaOnly));
503    }
504
505    #[test]
506    fn control_assessment_degrades_newer_firmware_to_a_warning_not_a_wall() {
507        let reg = default_registry();
508        // Newer-than-known firmware: strict permission walls, but the soft
509        // assessment only warns (degrade-not-wall) so an update doesn't break us.
510        let newer = resolve(&reg, &Model::A1Mini, &fw("01.99.00"));
511        assert_eq!(
512            newer.control_permission(),
513            Err(ControlRefusal::FirmwareNewerThanKnown)
514        );
515        assert_eq!(
516            newer.control_assessment(),
517            ControlAssessment::NewerFirmwareUntested
518        );
519
520        // A supported A1 mini still requires Developer Mode in both views.
521        let ok = resolve(&reg, &Model::A1Mini, &fw("01.07.02"));
522        assert_eq!(
523            ok.control_assessment(),
524            ControlAssessment::RequiresDeveloperMode
525        );
526
527        // An unknown model is refused in both views (no degrade for that).
528        let unknown = resolve(&reg, &Model::Unknown("z9".into()), &fw("01.00.00"));
529        assert_eq!(
530            unknown.control_assessment(),
531            ControlAssessment::Refused(ControlRefusal::UnknownModel)
532        );
533
534        // Developer Mode unavailable is a hard refusal even in the soft view.
535        let below = resolve(&reg, &Model::A1Mini, &fw("01.04.99"));
536        assert_eq!(
537            below.control_assessment(),
538            ControlAssessment::Refused(ControlRefusal::DeveloperModeUnavailable)
539        );
540    }
541
542    #[test]
543    fn x1_carbon_is_full_push_rtsp_and_has_lidar() {
544        let reg = default_registry();
545        let caps = resolve(&reg, &Model::X1Carbon, &fw("01.08.03"));
546        assert_eq!(caps.push_mode, Some(PushMode::Full));
547        assert_eq!(caps.camera_transport, Some(CameraTransport::Rtsp322));
548        let hw = caps.hardware.unwrap();
549        assert!(hw.lidar);
550        assert_eq!(hw.chamber_temperature, ChamberTemperature::RealSensor);
551        assert_eq!(
552            caps.control_permission(),
553            Ok(ControlPermit::RequiresDeveloperMode)
554        );
555    }
556
557    #[test]
558    fn inferred_h2d_keeps_descriptive_info_but_refuses_control() {
559        // The crux of descriptive-vs-control separation: H2D facts are present
560        // for discovery, but with no confirmed control boundary control is
561        // refused even though the model and firmware are "in range".
562        let reg = default_registry();
563        let caps = resolve(&reg, &Model::H2D, &fw("01.01.05"));
564        assert_eq!(caps.registry_status, RegistryStatus::Supported);
565        assert_eq!(caps.push_mode, Some(PushMode::Full)); // descriptive present
566        assert_eq!(caps.evidence, Some(EvidenceGrade::Inferred));
567        assert_eq!(caps.developer_mode, None);
568        assert_eq!(
569            caps.control_permission(),
570            Err(ControlRefusal::UnknownControlBoundary)
571        );
572    }
573}