1use std::collections::BTreeMap;
16
17use schemars::{JsonSchema, generate::SchemaSettings};
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20use serde_json::Value;
21
22use crate::action::{
23 AcknowledgementDisposition, ActionKind, ActionRecord, ActionRequest, Actor, ActorClass,
24 ExecutionOutcome, PrintAction,
25};
26use crate::adjustable::Adjustable;
27use crate::assessment::{AgentAssessment, Confidence};
28use crate::context::PrintContext;
29use crate::event::{
30 ActionExecutedPayload, ActionRejectedPayload, ActionRequestedPayload, AgentAssessmentPayload,
31 EventKind, EventPayload, EventRecord, EventSource, InterventionExpiredPayload,
32 MalformedExternalEventPayload, ObicoFailureAlertPayload, ObicoNotificationType,
33 ObicoPrinterNotificationPayload, OperatorAcknowledgementPayload, PortFailurePayload,
34 PortFailureSite, StartupOutcome, StartupReconciliationPayload, SupervisionSessionClosedPayload,
35 SupervisionSessionOpenedPayload,
36};
37use crate::file_name::FileName;
38use crate::ids::{ActionId, EventId, ImageId, InterventionId, PrintId};
39use crate::image::{ImageRecord, ImageRef};
40use crate::intervention::{Intervention, InterventionOutcome};
41use crate::manifest::JobManifest;
42use crate::obico::{
43 ObicoEventType, ObicoFailureAlert, ObicoFailureEvent, ObicoFailureEventType,
44 ObicoNotificationEvent, ObicoPrintInfo, ObicoPrinterInfo, ObicoPrinterNotification,
45 ObicoTimestamp,
46};
47use crate::policy::{EffectiveBounds, PolicyDecision, RejectionReason, SafetyEnvelope};
48use crate::print::{ManifestNarrowing, PrintRecord};
49use crate::printer::{HeaterSnapshot, JobSnapshot, PrinterSnapshot, PrinterState};
50use crate::raw::RawBytes;
51use crate::reported::{
52 COMPLETION_RANGE, FAN_PERCENT_RANGE, FEEDRATE_FACTOR_RANGE, FLOWRATE_FACTOR_RANGE,
53 HEATER_ACTUAL_C_RANGE, HEATER_OFFSET_C_RANGE, HEATER_TARGET_C_RANGE, Range, Reported,
54};
55use crate::session::{SessionPhase, SupervisionSession};
56use crate::timestamp::Timestamp;
57
58#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct RangedField {
61 pub type_name: &'static str,
63 pub field: &'static str,
65 pub range: Range,
67}
68
69pub const RANGED_FIELDS: [RangedField; 7] = [
75 RangedField {
76 type_name: "PrinterSnapshot",
77 field: "feedrate_factor",
78 range: FEEDRATE_FACTOR_RANGE,
79 },
80 RangedField {
81 type_name: "PrinterSnapshot",
82 field: "flowrate_factor",
83 range: FLOWRATE_FACTOR_RANGE,
84 },
85 RangedField {
86 type_name: "PrinterSnapshot",
87 field: "fan_percent",
88 range: FAN_PERCENT_RANGE,
89 },
90 RangedField {
91 type_name: "JobSnapshot",
92 field: "completion",
93 range: COMPLETION_RANGE,
94 },
95 RangedField {
96 type_name: "HeaterSnapshot",
97 field: "actual_c",
98 range: HEATER_ACTUAL_C_RANGE,
99 },
100 RangedField {
101 type_name: "HeaterSnapshot",
102 field: "target_c",
103 range: HEATER_TARGET_C_RANGE,
104 },
105 RangedField {
106 type_name: "HeaterSnapshot",
107 field: "offset_c",
108 range: HEATER_OFFSET_C_RANGE,
109 },
110];
111
112pub trait Sample: Sized {
114 fn sample_full() -> Self;
116
117 #[must_use]
119 fn sample_minimal() -> Self {
120 Self::sample_full()
121 }
122
123 #[must_use]
129 fn sample_alternates() -> Vec<Self> {
130 Vec::new()
131 }
132}
133
134#[derive(Debug, Clone, Copy)]
136pub struct TypeContract {
137 pub name: &'static str,
139 schema: fn() -> Value,
141 full: fn() -> Value,
143 minimal: fn() -> Value,
145 round_trip: fn(Value) -> Result<Value, String>,
147 alternates: fn() -> Vec<Value>,
149}
150
151impl TypeContract {
152 #[must_use]
154 pub fn schema(&self) -> Value {
155 (self.schema)()
156 }
157
158 #[must_use]
160 pub fn full(&self) -> Value {
161 (self.full)()
162 }
163
164 #[must_use]
166 pub fn minimal(&self) -> Value {
167 (self.minimal)()
168 }
169
170 #[must_use]
173 pub fn samples(&self) -> Vec<Value> {
174 let mut values = vec![self.full(), self.minimal()];
175 values.extend((self.alternates)());
176 values
177 }
178
179 pub fn round_trip(&self, value: Value) -> Result<Value, String> {
185 (self.round_trip)(value)
186 }
187}
188
189#[must_use]
198pub fn schema_of<T: JsonSchema>() -> Value {
199 let generator = SchemaSettings::draft2020_12().into_generator();
200 serde_json::to_value(generator.into_root_schema_for::<T>())
201 .expect("a generated schema is JSON by construction")
202}
203
204fn emit<T: Serialize>(value: &T) -> Result<Value, String> {
206 serde_json::to_value(value).map_err(|error| error.to_string())
207}
208
209fn round_trip_as<T: Serialize + DeserializeOwned>(value: Value) -> Result<Value, String> {
211 let parsed: T = serde_json::from_value(value).map_err(|error| error.to_string())?;
212 emit(&parsed)
213}
214
215fn canonical<T: Serialize>(value: &T) -> Value {
217 emit(value).expect("a canonical sample serializes")
218}
219
220macro_rules! contract_of {
222 ($ty:ty) => {
223 contract_of!($ty, stringify!($ty))
224 };
225 ($ty:ty, $name:expr) => {
226 TypeContract {
227 name: $name,
228 schema: schema_of::<$ty>,
229 full: || canonical(&<$ty as Sample>::sample_full()),
230 minimal: || canonical(&<$ty as Sample>::sample_minimal()),
231 round_trip: round_trip_as::<$ty>,
232 alternates: || {
233 <$ty as Sample>::sample_alternates()
234 .iter()
235 .map(canonical)
236 .collect()
237 },
238 }
239 };
240}
241
242macro_rules! declare {
244 ($($ty:ty $(=> $name:literal)?),* $(,)?) => {
245 vec![$( contract_of!($ty $(, $name)?) ),*]
246 };
247}
248
249#[must_use]
251pub fn declared() -> Vec<TypeContract> {
252 declare![
253 AcknowledgementDisposition,
254 ActionExecutedPayload,
255 ActionId,
256 ActionKind,
257 ActionRecord,
258 ActionRejectedPayload,
259 ActionRequest,
260 ActionRequestedPayload,
261 Actor,
262 ActorClass,
263 Adjustable,
264 AgentAssessment,
265 AgentAssessmentPayload,
266 Confidence,
267 EffectiveBounds,
268 EventId,
269 EventKind,
270 EventPayload,
271 EventRecord,
272 EventSource,
273 ExecutionOutcome,
274 FileName,
275 HeaterSnapshot,
276 ImageId,
277 ImageRecord,
278 ImageRef,
279 Intervention,
280 InterventionExpiredPayload,
281 InterventionId,
282 InterventionOutcome,
283 JobManifest,
284 JobSnapshot,
285 MalformedExternalEventPayload,
286 ManifestNarrowing,
287 ObicoEventType,
288 ObicoFailureAlert,
289 ObicoFailureAlertPayload,
290 ObicoFailureEvent,
291 ObicoFailureEventType,
292 ObicoNotificationEvent,
293 ObicoNotificationType,
294 ObicoPrintInfo,
295 ObicoPrinterInfo,
296 ObicoPrinterNotification,
297 ObicoPrinterNotificationPayload,
298 ObicoTimestamp,
299 OperatorAcknowledgementPayload,
300 PolicyDecision,
301 PortFailurePayload,
302 PortFailureSite,
303 PrintAction,
304 PrintContext,
305 PrintId,
306 PrintRecord,
307 PrinterSnapshot,
308 PrinterState,
309 Range,
310 RawBytes,
311 RejectionReason,
312 Reported<f64> => "Reported",
313 SafetyEnvelope,
314 SessionPhase,
315 StartupOutcome,
316 StartupReconciliationPayload,
317 SupervisionSession,
318 SupervisionSessionClosedPayload,
319 SupervisionSessionOpenedPayload,
320 Timestamp,
321 ]
322}
323
324pub const PORT_OWNED_TYPES: [&str; 10] = [
330 "PrinterError",
331 "VisionError",
332 "SupervisorError",
333 "StoreError",
334 "NormalizedAlert",
335 "FetchedImage",
336 "TurnRequest",
337 "TurnOutcome",
338 "EventDraft",
339 "HistoryQuery",
340];
341
342fn print_id() -> PrintId {
344 "0191f0a0-0000-7000-8000-000000000001"
345 .parse()
346 .expect("a fixed version 7 identifier")
347}
348
349fn event_id() -> EventId {
351 "0191f0a0-0000-7000-8000-000000000002"
352 .parse()
353 .expect("a fixed version 7 identifier")
354}
355
356fn image_id() -> ImageId {
358 "0191f0a0-0000-7000-8000-000000000003"
359 .parse()
360 .expect("a fixed version 7 identifier")
361}
362
363fn action_id() -> ActionId {
365 "0191f0a0-0000-7000-8000-000000000004"
366 .parse()
367 .expect("a fixed version 7 identifier")
368}
369
370fn intervention_id() -> InterventionId {
372 "0191f0a0-0000-7000-8000-000000000005"
373 .parse()
374 .expect("a fixed version 7 identifier")
375}
376
377fn instant() -> Timestamp {
379 "2026-03-01T12:00:00Z"
380 .parse()
381 .expect("a fixed RFC 3339 instant")
382}
383
384fn later_instant() -> Timestamp {
386 "2026-03-01T12:30:00Z"
387 .parse()
388 .expect("a fixed RFC 3339 instant")
389}
390
391impl Sample for PrintId {
392 fn sample_full() -> Self {
393 print_id()
394 }
395}
396
397impl Sample for EventId {
398 fn sample_full() -> Self {
399 event_id()
400 }
401}
402
403impl Sample for ImageId {
404 fn sample_full() -> Self {
405 image_id()
406 }
407}
408
409impl Sample for ActionId {
410 fn sample_full() -> Self {
411 action_id()
412 }
413}
414
415impl Sample for InterventionId {
416 fn sample_full() -> Self {
417 intervention_id()
418 }
419}
420
421impl Sample for Timestamp {
422 fn sample_full() -> Self {
423 instant()
424 }
425}
426
427impl Sample for FileName {
428 fn sample_full() -> Self {
429 Self::new("benchy.gcode").expect("a name carrying no forbidden property")
430 }
431}
432
433impl Sample for RawBytes {
434 fn sample_full() -> Self {
435 Self::new(b"{\"event\":{}}".to_vec())
436 }
437}
438
439impl Sample for Range {
440 fn sample_full() -> Self {
441 Self::new(0.5, 1.5)
442 }
443}
444
445impl Sample for Reported<f64> {
446 fn sample_full() -> Self {
447 Self::new(1.0, FEEDRATE_FACTOR_RANGE)
448 }
449}
450
451impl Sample for Adjustable {
452 fn sample_full() -> Self {
453 Self::ToolTarget { tool: 0 }
454 }
455}
456
457impl Sample for PrinterState {
458 fn sample_full() -> Self {
459 Self::Printing
460 }
461}
462
463impl Sample for HeaterSnapshot {
464 fn sample_full() -> Self {
465 Self {
466 actual_c: Some(Reported::new(214.5, HEATER_ACTUAL_C_RANGE)),
467 target_c: Some(Reported::new(215.0, HEATER_TARGET_C_RANGE)),
468 offset_c: Some(Reported::new(0.0, HEATER_OFFSET_C_RANGE)),
469 }
470 }
471
472 fn sample_minimal() -> Self {
473 Self {
474 actual_c: None,
475 target_c: None,
476 offset_c: None,
477 }
478 }
479}
480
481impl Sample for PrinterSnapshot {
482 fn sample_full() -> Self {
483 Self {
484 connection: PrinterState::Printing,
485 tools: vec![HeaterSnapshot::sample_full()],
486 bed: Some(HeaterSnapshot::sample_full()),
487 chamber: Some(HeaterSnapshot::sample_full()),
488 feedrate_factor: Some(Reported::new(1.0, FEEDRATE_FACTOR_RANGE)),
489 flowrate_factor: Some(Reported::new(1.0, FLOWRATE_FACTOR_RANGE)),
490 fan_percent: Some(Reported::new(100.0, FAN_PERCENT_RANGE)),
491 observed_at: instant(),
492 }
493 }
494
495 fn sample_minimal() -> Self {
496 Self {
497 connection: PrinterState::Printing,
498 tools: vec![],
499 bed: None,
500 chamber: None,
501 feedrate_factor: None,
502 flowrate_factor: None,
503 fan_percent: None,
504 observed_at: instant(),
505 }
506 }
507}
508
509impl Sample for JobSnapshot {
510 fn sample_full() -> Self {
511 Self {
512 file_name: Some("benchy.gcode".to_owned()),
513 file_origin: Some("local".to_owned()),
514 size_bytes: Some(4_194_304),
515 estimated_print_time_s: Some(7200),
516 completion: Some(Reported::new(0.42, COMPLETION_RANGE)),
517 print_time_s: Some(3024),
518 print_time_left_s: Some(4176),
519 state: PrinterState::Printing,
520 error: Some("none".to_owned()),
521 }
522 }
523
524 fn sample_minimal() -> Self {
525 Self {
526 file_name: None,
527 file_origin: None,
528 size_bytes: None,
529 estimated_print_time_s: None,
530 completion: None,
531 print_time_s: None,
532 print_time_left_s: None,
533 state: PrinterState::Printing,
534 error: None,
535 }
536 }
537}
538
539impl Sample for ManifestNarrowing {
540 fn sample_full() -> Self {
541 Self {
542 adjustable: Adjustable::Feedrate,
543 requested: Range::new(0.2, 4.0),
544 applied: Range::new(0.5, 1.5),
545 }
546 }
547}
548
549impl Sample for PrintRecord {
550 fn sample_full() -> Self {
551 Self {
552 id: print_id(),
553 obico_print_id: Some(4211),
554 file_name: Some("benchy.gcode".to_owned()),
555 state: PrinterState::Printing,
556 opened_at: instant(),
557 ended_at: Some(later_instant()),
558 end_reason: Some("finished".to_owned()),
559 narrowings: vec![ManifestNarrowing::sample_full()],
560 }
561 }
562
563 fn sample_minimal() -> Self {
564 Self {
565 id: print_id(),
566 obico_print_id: None,
567 file_name: None,
568 state: PrinterState::Printing,
569 opened_at: instant(),
570 ended_at: None,
571 end_reason: None,
572 narrowings: vec![],
573 }
574 }
575}
576
577impl Sample for ImageRecord {
578 fn sample_full() -> Self {
579 Self {
580 id: image_id(),
581 print_id: print_id(),
582 event_id: event_id(),
583 source_url: Some("https://obico.example/snapshots/1.jpg".to_owned()),
584 fetched_at: instant(),
585 content_type: "image/jpeg".to_owned(),
586 byte_len: 51_200,
587 sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned(),
588 relative_path: "images/0191f0a0/1.jpg".to_owned(),
589 }
590 }
591
592 fn sample_minimal() -> Self {
593 Self {
594 source_url: None,
595 ..Self::sample_full()
596 }
597 }
598}
599
600impl Sample for ImageRef {
601 fn sample_full() -> Self {
602 Self {
603 id: image_id(),
604 sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned(),
605 }
606 }
607}
608
609impl Sample for Actor {
610 fn sample_full() -> Self {
611 Self::Agent {
612 session_name: "print-0191f0a0".to_owned(),
613 }
614 }
615}
616
617impl Sample for ActorClass {
618 fn sample_full() -> Self {
619 Self::Agent
620 }
621}
622
623impl Sample for ActionKind {
624 fn sample_full() -> Self {
625 Self::SetFeedrateFactor
626 }
627}
628
629impl Sample for AcknowledgementDisposition {
630 fn sample_full() -> Self {
631 Self::Watch
632 }
633}
634
635impl Sample for ExecutionOutcome {
636 fn sample_full() -> Self {
637 Self::Failed {
638 reason: "the printer refused the command".to_owned(),
639 }
640 }
641}
642
643impl Sample for PrintAction {
644 fn sample_full() -> Self {
645 Self::SetFeedrateFactor {
646 factor: 0.8,
647 duration_s: Some(600),
648 reason: "the first layer is going down fast".to_owned(),
649 actor: Actor::sample_full(),
650 }
651 }
652
653 fn sample_minimal() -> Self {
654 Self::SetFeedrateFactor {
655 factor: 0.8,
656 duration_s: None,
657 reason: "the first layer is going down fast".to_owned(),
658 actor: Actor::sample_full(),
659 }
660 }
661
662 fn sample_alternates() -> Vec<Self> {
663 let reason = || "the print needs it".to_owned();
664 vec![
665 Self::Pause {
666 reason: reason(),
667 actor: Actor::sample_full(),
668 },
669 Self::Resume {
670 reason: reason(),
671 actor: Actor::sample_full(),
672 },
673 Self::Cancel {
674 reason: reason(),
675 actor: Actor::sample_full(),
676 },
677 Self::StartPrint {
678 file_name: FileName::sample_full(),
679 manifest: JobManifest::sample_full(),
680 reason: reason(),
681 actor: Actor::sample_full(),
682 },
683 Self::SetFlowrateFactor {
684 factor: 1.05,
685 duration_s: Some(300),
686 reason: reason(),
687 actor: Actor::sample_full(),
688 },
689 Self::SetToolTargetC {
690 tool: 0,
691 target_c: 215.0,
692 duration_s: Some(300),
693 reason: reason(),
694 actor: Actor::sample_full(),
695 },
696 Self::SetBedTargetC {
697 target_c: 60.0,
698 duration_s: Some(300),
699 reason: reason(),
700 actor: Actor::sample_full(),
701 },
702 Self::SetFanPercent {
703 percent: 80.0,
704 duration_s: Some(300),
705 reason: reason(),
706 actor: Actor::sample_full(),
707 },
708 Self::AcknowledgeFailure {
709 event_id: event_id(),
710 disposition: AcknowledgementDisposition::Watch,
711 reason: reason(),
712 actor: Actor::Operator,
713 },
714 ]
715 }
716}
717
718impl Sample for ActionRequest {
719 fn sample_full() -> Self {
720 Self {
721 action: PrintAction::sample_full(),
722 actor: Actor::sample_full(),
723 requested_at: instant(),
724 }
725 }
726
727 fn sample_minimal() -> Self {
728 Self {
729 action: PrintAction::sample_minimal(),
730 ..Self::sample_full()
731 }
732 }
733}
734
735impl Sample for ActionRecord {
736 fn sample_full() -> Self {
737 Self {
738 id: action_id(),
739 print_id: print_id(),
740 request: ActionRequest::sample_full(),
741 decision: PolicyDecision::Accepted,
742 executed_at: Some(later_instant()),
743 outcome: Some(ExecutionOutcome::Succeeded),
744 }
745 }
746
747 fn sample_minimal() -> Self {
748 Self {
749 request: ActionRequest::sample_minimal(),
750 executed_at: None,
751 outcome: None,
752 ..Self::sample_full()
753 }
754 }
755}
756
757fn allowed_ranges() -> BTreeMap<Adjustable, Range> {
759 BTreeMap::from([
760 (Adjustable::Feedrate, Range::new(0.5, 1.5)),
761 (Adjustable::ToolTarget { tool: 0 }, Range::new(190.0, 230.0)),
762 ])
763}
764
765impl Sample for JobManifest {
766 fn sample_full() -> Self {
767 Self {
768 file_name: "benchy.gcode".to_owned(),
769 material: "PLA".to_owned(),
770 nozzle_diameter_mm: 0.4,
771 slicer_profile: "0.2mm standard".to_owned(),
772 allowed: allowed_ranges(),
773 metadata: BTreeMap::from([("layer_height_mm".to_owned(), "0.2".to_owned())]),
774 }
775 }
776}
777
778impl Sample for SafetyEnvelope {
779 fn sample_full() -> Self {
780 Self {
781 allowed: allowed_ranges(),
782 actions: BTreeMap::from([(
783 ActorClass::Agent,
784 vec![ActionKind::Pause, ActionKind::SetFeedrateFactor],
785 )]),
786 agent_min_interval_s: 60,
787 }
788 }
789}
790
791impl Sample for EffectiveBounds {
792 fn sample_full() -> Self {
793 Self {
794 allowed: allowed_ranges(),
795 }
796 }
797}
798
799impl Sample for RejectionReason {
800 fn sample_full() -> Self {
801 Self::OutOfBounds {
802 adjustable: Adjustable::Feedrate,
803 requested: 4.0,
804 allowed: Range::new(0.5, 1.5),
805 }
806 }
807}
808
809impl Sample for PolicyDecision {
810 fn sample_full() -> Self {
811 Self::Rejected(RejectionReason::sample_full())
812 }
813}
814
815impl Sample for InterventionOutcome {
816 fn sample_full() -> Self {
817 Self::Superseded {
818 by: intervention_id(),
819 }
820 }
821}
822
823impl Sample for Intervention {
824 fn sample_full() -> Self {
825 Self {
826 id: intervention_id(),
827 print_id: print_id(),
828 action_id: action_id(),
829 adjustable: Adjustable::Feedrate,
830 prior_value: Some(1.0),
831 applied_value: 0.8,
832 applied_at: instant(),
833 expires_at: later_instant(),
834 restored_at: Some(later_instant()),
835 outcome: InterventionOutcome::Restored,
836 }
837 }
838
839 fn sample_minimal() -> Self {
840 Self {
841 prior_value: None,
842 restored_at: None,
843 outcome: InterventionOutcome::StillActive,
844 ..Self::sample_full()
845 }
846 }
847}
848
849impl Sample for SessionPhase {
850 fn sample_full() -> Self {
851 Self::Created
852 }
853}
854
855impl Sample for SupervisionSession {
856 fn sample_full() -> Self {
857 Self {
858 print_id: print_id(),
859 session_name: "print-0191f0a0".to_owned(),
860 harness_identity: "printobserver-supervisor".to_owned(),
861 created_at: instant(),
862 last_turn_at: later_instant(),
863 closed_at: Some(later_instant()),
864 close_reason: Some("the print ended".to_owned()),
865 }
866 }
867
868 fn sample_minimal() -> Self {
869 Self {
870 closed_at: None,
871 close_reason: None,
872 ..Self::sample_full()
873 }
874 }
875}
876
877impl Sample for Confidence {
878 fn sample_full() -> Self {
879 Self::Medium
880 }
881}
882
883impl Sample for AgentAssessment {
884 fn sample_full() -> Self {
885 Self {
886 summary: "the first layer is down and adhesion looks even".to_owned(),
887 confidence: Confidence::Medium,
888 should_continue: true,
889 did: "slowed the feedrate to 80% for ten minutes".to_owned(),
890 why: "the extrusion width was widening on the long edges".to_owned(),
891 escalating: false,
892 }
893 }
894}
895
896impl Sample for EventSource {
897 fn sample_full() -> Self {
898 Self::Obico
899 }
900}
901
902impl Sample for ObicoNotificationType {
903 fn sample_full() -> Self {
904 Self::Started
905 }
906}
907
908impl Sample for EventKind {
909 fn sample_full() -> Self {
910 Self::ObicoFailureAlert
911 }
912}
913
914impl Sample for ObicoFailureAlertPayload {
915 fn sample_full() -> Self {
916 Self {
917 is_warning: true,
918 print_paused: false,
919 obico_print_id: Some(4211),
920 file_name: Some("benchy.gcode".to_owned()),
921 started_at: Some(instant()),
922 ended_at: Some(later_instant()),
923 }
924 }
925
926 fn sample_minimal() -> Self {
927 Self {
928 obico_print_id: None,
929 file_name: None,
930 started_at: None,
931 ended_at: None,
932 ..Self::sample_full()
933 }
934 }
935}
936
937impl Sample for ObicoPrinterNotificationPayload {
938 fn sample_full() -> Self {
939 Self {
940 notification_type: ObicoNotificationType::Started,
941 obico_print_id: Some(4211),
942 file_name: Some("benchy.gcode".to_owned()),
943 started_at: Some(instant()),
944 ended_at: Some(later_instant()),
945 }
946 }
947
948 fn sample_minimal() -> Self {
949 Self {
950 obico_print_id: None,
951 file_name: None,
952 started_at: None,
953 ended_at: None,
954 ..Self::sample_full()
955 }
956 }
957}
958
959impl Sample for PortFailureSite {
960 fn sample_full() -> Self {
961 Self::PrinterSnapshot
962 }
963}
964
965impl Sample for PortFailurePayload {
966 fn sample_full() -> Self {
967 Self {
968 event_id: event_id(),
969 site: PortFailureSite::sample_full(),
970 detail: "the printer is unreachable: connection refused".to_owned(),
971 }
972 }
973}
974
975impl Sample for StartupOutcome {
976 fn sample_full() -> Self {
977 Self::InterventionExpired {
978 intervention_id: intervention_id(),
979 adjustable: Adjustable::Fan,
980 outcome: InterventionOutcome::Restored,
981 }
982 }
983
984 fn sample_alternates() -> Vec<Self> {
985 vec![
986 Self::PrintAdopted,
987 Self::SessionResumed {
988 session_name: "watch-4211".to_owned(),
989 },
990 ]
991 }
992}
993
994impl Sample for StartupReconciliationPayload {
995 fn sample_full() -> Self {
996 Self {
997 print_id: print_id(),
998 outcome: StartupOutcome::sample_full(),
999 }
1000 }
1001}
1002
1003impl Sample for MalformedExternalEventPayload {
1004 fn sample_full() -> Self {
1005 Self {
1006 detail: "the body is not JSON: expected value at line 1 column 1".to_owned(),
1007 }
1008 }
1009}
1010
1011impl Sample for ActionRequestedPayload {
1012 fn sample_full() -> Self {
1013 Self {
1014 action_id: action_id(),
1015 action: PrintAction::sample_full(),
1016 actor: Actor::sample_full(),
1017 }
1018 }
1019
1020 fn sample_minimal() -> Self {
1021 Self {
1022 action: PrintAction::sample_minimal(),
1023 ..Self::sample_full()
1024 }
1025 }
1026}
1027
1028impl Sample for ActionExecutedPayload {
1029 fn sample_full() -> Self {
1030 Self {
1031 action_id: action_id(),
1032 intervention_id: Some(intervention_id()),
1033 }
1034 }
1035
1036 fn sample_minimal() -> Self {
1037 Self {
1038 action_id: action_id(),
1039 intervention_id: None,
1040 }
1041 }
1042}
1043
1044impl Sample for ActionRejectedPayload {
1045 fn sample_full() -> Self {
1046 Self {
1047 action_id: action_id(),
1048 decision: PolicyDecision::sample_full(),
1049 }
1050 }
1051}
1052
1053impl Sample for InterventionExpiredPayload {
1054 fn sample_full() -> Self {
1055 Self {
1056 intervention_id: intervention_id(),
1057 adjustable: Adjustable::Feedrate,
1058 outcome: InterventionOutcome::Restored,
1059 }
1060 }
1061}
1062
1063impl Sample for SupervisionSessionOpenedPayload {
1064 fn sample_full() -> Self {
1065 Self {
1066 session_name: "print-0191f0a0".to_owned(),
1067 harness_identity: "printobserver-supervisor".to_owned(),
1068 }
1069 }
1070}
1071
1072impl Sample for SupervisionSessionClosedPayload {
1073 fn sample_full() -> Self {
1074 Self {
1075 session_name: "print-0191f0a0".to_owned(),
1076 close_reason: "the print ended".to_owned(),
1077 }
1078 }
1079}
1080
1081impl Sample for AgentAssessmentPayload {
1082 fn sample_full() -> Self {
1083 Self {
1084 session_name: "print-0191f0a0".to_owned(),
1085 assessment: AgentAssessment::sample_full(),
1086 }
1087 }
1088}
1089
1090impl Sample for OperatorAcknowledgementPayload {
1091 fn sample_full() -> Self {
1092 Self {
1093 acknowledged_event_id: event_id(),
1094 disposition: AcknowledgementDisposition::Watch,
1095 }
1096 }
1097}
1098
1099impl Sample for EventPayload {
1100 fn sample_full() -> Self {
1101 Self::ObicoFailureAlert(ObicoFailureAlertPayload::sample_full())
1102 }
1103
1104 fn sample_minimal() -> Self {
1105 Self::ObicoFailureAlert(ObicoFailureAlertPayload::sample_minimal())
1106 }
1107
1108 fn sample_alternates() -> Vec<Self> {
1109 vec![
1110 Self::ObicoPrinterNotification(ObicoPrinterNotificationPayload::sample_full()),
1111 Self::MalformedExternalEvent(MalformedExternalEventPayload::sample_full()),
1112 Self::ActionRequested(ActionRequestedPayload::sample_full()),
1113 Self::ActionExecuted(ActionExecutedPayload::sample_full()),
1114 Self::ActionRejected(ActionRejectedPayload::sample_full()),
1115 Self::InterventionExpired(InterventionExpiredPayload::sample_full()),
1116 Self::SupervisionSessionOpened(SupervisionSessionOpenedPayload::sample_full()),
1117 Self::SupervisionSessionClosed(SupervisionSessionClosedPayload::sample_full()),
1118 Self::AgentAssessment(AgentAssessmentPayload::sample_full()),
1119 Self::OperatorAcknowledgement(OperatorAcknowledgementPayload::sample_full()),
1120 Self::PortFailure(PortFailurePayload::sample_full()),
1121 Self::StartupReconciliation(StartupReconciliationPayload::sample_full()),
1122 ]
1123 }
1124}
1125
1126impl Sample for EventRecord {
1127 fn sample_full() -> Self {
1128 Self {
1129 id: event_id(),
1130 print_id: Some(print_id()),
1131 source: EventSource::Obico,
1132 received_at: instant(),
1133 image: Some(ImageRef::sample_full()),
1134 payload: EventPayload::sample_full(),
1135 raw: Some(RawBytes::sample_full()),
1136 }
1137 }
1138
1139 fn sample_minimal() -> Self {
1140 Self {
1141 print_id: None,
1142 image: None,
1143 raw: None,
1144 payload: EventPayload::sample_minimal(),
1145 ..Self::sample_full()
1146 }
1147 }
1148
1149 fn sample_alternates() -> Vec<Self> {
1150 EventPayload::sample_alternates()
1151 .into_iter()
1152 .map(|payload| Self {
1153 payload,
1154 ..Self::sample_full()
1155 })
1156 .collect()
1157 }
1158}
1159
1160impl Sample for PrintContext {
1161 fn sample_full() -> Self {
1162 Self {
1163 print: PrintRecord::sample_full(),
1164 printer: Some(PrinterSnapshot::sample_full()),
1165 job: Some(JobSnapshot::sample_full()),
1166 manifest: Some(JobManifest::sample_full()),
1167 bounds: EffectiveBounds::sample_full(),
1168 interventions: vec![Intervention::sample_full()],
1169 recent_events: vec![EventRecord::sample_full()],
1170 latest_image: Some(ImageRef::sample_full()),
1171 }
1172 }
1173
1174 fn sample_minimal() -> Self {
1175 Self {
1176 print: PrintRecord::sample_minimal(),
1177 printer: None,
1178 job: None,
1179 manifest: None,
1180 bounds: EffectiveBounds::sample_full(),
1181 interventions: vec![],
1182 recent_events: vec![],
1183 latest_image: None,
1184 }
1185 }
1186}
1187
1188impl Sample for ObicoFailureEventType {
1189 fn sample_full() -> Self {
1190 Self::PrintFailure
1191 }
1192}
1193
1194impl Sample for ObicoEventType {
1195 fn sample_full() -> Self {
1196 Self::PrintStarted
1197 }
1198}
1199
1200impl Sample for ObicoTimestamp {
1201 fn sample_full() -> Self {
1202 Self::Seconds(1_772_366_400.5)
1203 }
1204}
1205
1206impl Sample for ObicoPrinterInfo {
1207 fn sample_full() -> Self {
1208 Self {
1209 id: 17,
1210 name: "Prusa MK4".to_owned(),
1211 }
1212 }
1213}
1214
1215impl Sample for ObicoPrintInfo {
1216 fn sample_full() -> Self {
1217 Self {
1218 id: 4211,
1219 filename: "benchy.gcode".to_owned(),
1220 started_at: Some(ObicoTimestamp::Seconds(1_772_366_400.5)),
1221 ended_at: Some(ObicoTimestamp::NotReported),
1222 }
1223 }
1224
1225 fn sample_minimal() -> Self {
1226 Self {
1227 started_at: None,
1228 ended_at: None,
1229 ..Self::sample_full()
1230 }
1231 }
1232}
1233
1234impl Sample for ObicoFailureEvent {
1235 fn sample_full() -> Self {
1236 Self {
1237 event_type: ObicoFailureEventType::PrintFailure,
1238 is_warning: true,
1239 print_paused: false,
1240 }
1241 }
1242}
1243
1244impl Sample for ObicoNotificationEvent {
1245 fn sample_full() -> Self {
1246 Self {
1247 event_type: ObicoEventType::PrintStarted,
1248 is_warning: false,
1249 print_paused: false,
1250 }
1251 }
1252}
1253
1254impl Sample for ObicoFailureAlert {
1255 fn sample_full() -> Self {
1256 Self {
1257 event: ObicoFailureEvent::sample_full(),
1258 printer: ObicoPrinterInfo::sample_full(),
1259 print: ObicoPrintInfo::sample_full(),
1260 img_url: "https://obico.example/snapshots/4211.jpg".to_owned(),
1261 }
1262 }
1263}
1264
1265impl Sample for ObicoPrinterNotification {
1266 fn sample_full() -> Self {
1267 Self {
1268 event: ObicoNotificationEvent::sample_full(),
1269 printer: ObicoPrinterInfo::sample_full(),
1270 print: Some(ObicoPrintInfo::sample_full()),
1271 img_url: Some("https://obico.example/snapshots/4211.jpg".to_owned()),
1272 }
1273 }
1274
1275 fn sample_minimal() -> Self {
1276 Self {
1277 print: None,
1278 img_url: None,
1279 ..Self::sample_full()
1280 }
1281 }
1282}
1283
1284#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1289pub struct WireField {
1290 pub name: String,
1292 pub descriptor: String,
1294 pub required: bool,
1296}
1297
1298fn referenced(reference: &str) -> String {
1300 reference.rsplit('/').next().unwrap_or(reference).to_owned()
1301}
1302
1303fn sole_arm(arms: &[Value]) -> Option<&Value> {
1305 let mut kept = arms
1306 .iter()
1307 .filter(|arm| arm.get("type") != Some(&Value::from("null")));
1308 let first = kept.next()?;
1309 kept.next().is_none().then_some(first)
1310}
1311
1312fn descriptor(schema: &Value) -> String {
1314 if let Some(Value::String(reference)) = schema.get("$ref") {
1315 return referenced(reference);
1316 }
1317 for key in ["anyOf", "oneOf"] {
1318 if let Some(Value::Array(arms)) = schema.get(key) {
1319 return match sole_arm(arms) {
1320 Some(arm) => descriptor(arm),
1321 None => "enum".to_owned(),
1322 };
1323 }
1324 }
1325 let kind = match schema.get("type") {
1326 Some(Value::String(name)) => name.clone(),
1327 Some(Value::Array(names)) => names
1328 .iter()
1329 .filter_map(Value::as_str)
1330 .find(|name| *name != "null")
1331 .unwrap_or("unknown")
1332 .to_owned(),
1333 _ => "unknown".to_owned(),
1334 };
1335 match kind.as_str() {
1336 "array" => format!(
1337 "array:{}",
1338 schema
1339 .get("items")
1340 .map_or_else(|| "unknown".to_owned(), descriptor)
1341 ),
1342 "object" => match (
1343 schema.get("additionalProperties"),
1344 schema.get("patternProperties"),
1345 ) {
1346 (Some(value), _) if value.is_object() => format!("map:{}", descriptor(value)),
1347 (_, Some(Value::Object(patterns))) if patterns.len() == 1 => {
1348 let value = patterns.values().next().expect("one pattern");
1349 format!("map:{}", descriptor(value))
1350 }
1351 _ => "object".to_owned(),
1352 },
1353 other => other.to_owned(),
1354 }
1355}
1356
1357fn required_names(schema: &Value) -> Vec<&str> {
1359 schema
1360 .get("required")
1361 .and_then(Value::as_array)
1362 .map(|names| names.iter().filter_map(Value::as_str).collect())
1363 .unwrap_or_default()
1364}
1365
1366fn collect(schema: &Value, into: &mut BTreeMap<String, WireField>) {
1368 let required = required_names(schema);
1369 let Some(Value::Object(properties)) = schema.get("properties") else {
1370 return;
1371 };
1372 for (name, property) in properties {
1373 into.insert(
1374 name.clone(),
1375 WireField {
1376 name: name.clone(),
1377 descriptor: descriptor(property),
1378 required: required.contains(&name.as_str()),
1379 },
1380 );
1381 }
1382}
1383
1384#[must_use]
1391pub fn wire_fields(schema: &Value) -> Vec<WireField> {
1392 let mut fields = BTreeMap::new();
1393 collect(schema, &mut fields);
1394 for key in ["anyOf", "oneOf", "allOf"] {
1395 let Some(Value::Array(arms)) = schema.get(key) else {
1396 continue;
1397 };
1398 let mut from_arms: BTreeMap<String, WireField> = BTreeMap::new();
1399 for arm in arms {
1400 let mut one = BTreeMap::new();
1401 collect(arm, &mut one);
1402 for (name, field) in one {
1403 from_arms
1404 .entry(name)
1405 .and_modify(|held| {
1406 held.required &= field.required;
1407 if held.descriptor != field.descriptor {
1408 "union".clone_into(&mut held.descriptor);
1409 }
1410 })
1411 .or_insert(field);
1412 }
1413 }
1414 fields.extend(from_arms);
1415 }
1416 fields.into_values().collect()
1417}