1use crate::Failure;
5use anyhow::{Result, ensure};
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8
9#[derive(Clone, Copy)]
10pub enum RecoveryType {
11 Integer(u32, u32),
12 PowerOfTwo(u32, u32),
13 Number {
14 minimum: f64,
15 exclusive: bool,
16 maximum: f64,
17 },
18 Boolean,
19 Choice(&'static [&'static str]),
20}
21pub struct RecoveryField {
22 pub key: &'static str,
23 pub label: &'static str,
24 pub description: &'static str,
25 pub units: &'static str,
26 pub step: f64,
27 pub default: Value,
28 pub value_type: RecoveryType,
29}
30impl RecoveryField {
31 pub fn section(&self) -> &'static str {
32 match self.key {
33 "coolingTimeoutSeconds"
34 | "temperatureToleranceC"
35 | "coolingStableSamples"
36 | "coolingSampleSeconds"
37 | "coolerMaxPowerChangePer5Seconds" => "Cooling",
38 "commandTimeoutSeconds"
39 | "downloadTimeoutSeconds"
40 | "exposureGraceSeconds"
41 | "directReadChunkKiB"
42 | "transferLogMode" => "Timeouts",
43 _ => "Recovery",
44 }
45 }
46 pub fn accepts(&self, value: &Value) -> bool {
47 match self.value_type {
48 RecoveryType::Boolean => value.is_boolean(),
49 RecoveryType::Choice(choices) => value.as_str().is_some_and(|v| choices.contains(&v)),
50 RecoveryType::PowerOfTwo(min, max) => value
51 .as_u64()
52 .is_some_and(|v| (min as u64..=max as u64).contains(&v) && v.is_power_of_two()),
53 RecoveryType::Integer(min, max) => value
54 .as_u64()
55 .is_some_and(|v| (min as u64..=max as u64).contains(&v)),
56 RecoveryType::Number {
57 minimum,
58 exclusive,
59 maximum,
60 } => value.as_f64().is_some_and(|v| {
61 v.is_finite() && v <= maximum && if exclusive { v > minimum } else { v >= minimum }
62 }),
63 }
64 }
65 pub fn schema(&self) -> Value {
66 let mut schema = json!({
67 "title":self.label,"description":self.description,"default":self.default,
68 "x-regain":{"key":self.key,"label":self.label,"description":self.description,
69 "group":"Native camera recovery","units":self.units,"step":self.step,
70 "apply":if matches!(self.key, "transferLogMode" | "coolerMaxPowerChangePer5Seconds") {"live"} else {"reconnect"},"sensitive":false,"section":self.section(),
71 "platforms":if self.key == "usbPortCycle" {vec!["linux"]} else {vec!["windows","linux","macos"]}}
72 });
73 match self.value_type {
74 RecoveryType::Boolean => schema["type"] = json!("boolean"),
75 RecoveryType::Choice(choices) => {
76 schema["type"] = json!("string");
77 schema["enum"] = json!(choices);
78 }
79 RecoveryType::PowerOfTwo(min, max) => {
80 schema["type"] = json!("integer");
81 schema["minimum"] = json!(min);
82 schema["maximum"] = json!(max);
83 schema["enum"] = json!(
84 (min..=max)
85 .filter(|v| v.is_power_of_two())
86 .collect::<Vec<_>>()
87 );
88 }
89 RecoveryType::Integer(min, max) => {
90 schema["type"] = json!("integer");
91 schema["minimum"] = json!(min);
92 schema["maximum"] = json!(max);
93 }
94 RecoveryType::Number {
95 minimum,
96 exclusive,
97 maximum,
98 } => {
99 schema["type"] = json!("number");
100 schema[if exclusive {
101 "exclusiveMinimum"
102 } else {
103 "minimum"
104 }] = json!(minimum);
105 schema["maximum"] = json!(maximum);
106 }
107 }
108 schema
109 }
110}
111macro_rules! recovery {
112 ($($field:ident : $ty:ty = $default:expr => ($key:literal, $label:literal, $description:literal, $units:literal, $step:expr, $kind:expr)),+ $(,)?) => {
113 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
114 #[serde(default)]
115 pub struct RecoveryOptions { $(#[serde(rename = $key)] pub $field: $ty),+ }
116 impl Default for RecoveryOptions {
117 fn default() -> Self { Self { $($field:$default),+ } }
118 }
119 impl RecoveryOptions {
120 pub fn fields() -> Vec<RecoveryField> {
121 vec![$(RecoveryField {key:$key,label:$label,description:$description,units:$units,step:$step,default:json!($default),value_type:$kind}),+]
122 }
123 pub fn validate(&self) -> Result<()> {
124 let values = serde_json::to_value(self)?;
125 for field in Self::fields() {
126 ensure!(field.accepts(&values[field.key]), Failure::Invalid(format!("Invalid camera recovery setting: {}",field.key)));
127 }
128 Ok(())
129 }
130 pub fn schema() -> Value {
133 let properties: serde_json::Map<String, Value> = Self::fields().into_iter().enumerate().map(|(order, field)| {
134 let mut schema = field.schema();
135 schema["x-regain"]["order"] = json!(order);
136 (field.key.into(),schema)
137 }).collect();
138 json!({"type":"object","additionalProperties":false,"default":Self::default(),"properties":properties})
139 }
140 }
141 }
142}
143use RecoveryType::{Boolean, Choice, Integer, Number, PowerOfTwo};
144recovery! {
145 max_retries: u32 = 3 => ("maxRetries", "Replacement exposures", "Maximum replacement exposures after a recoverable failure. Zero disables replacement exposures.", "retries", 1.0, Integer(0,20)),
146 pre_exposure_reconnect_attempts: u32 = 3 => ("preExposureReconnectAttempts", "Pre-exposure reconnect attempts", "Maximum connection/restoration attempts before dispatching the first exposure start, including a failed initial preparation. Applies to long exposures without permitting their replacement. Telemetry holds its last reading during an outage; capture owns bounded reconnection. Uncertain control writes are never replayed by this retry policy.", "attempts", 1.0, Integer(1,10)),
147 maximum_retry_exposure_seconds: f64 = 30.0 => ("maximumRetryExposureSeconds", "Replacement exposure limit", "Only exposures at or below this duration may be replaced. Same-frame rereads do not start a replacement exposure.", "s", 1.0, Number {minimum:0.0,exclusive:false,maximum:86400.0}),
148 reconnect_delay_seconds: f64 = 5.0 => ("reconnectDelaySeconds", "Reconnect delay", "Wait before restoring a camera worker after a recoverable failure or an unacknowledged abort. A clean abort keeps the worker.", "s", 0.1, Number {minimum:0.0,exclusive:true,maximum:3600.0}),
149 command_timeout_seconds: f64 = 15.0 => ("commandTimeoutSeconds", "Command timeout", "Deadline for a native worker command. Gain, offset and cooler changes include readback within the same deadline.", "s", 1.0, Number {minimum:0.0,exclusive:true,maximum:3600.0}),
150 download_timeout_seconds: f64 = 60.0 => ("downloadTimeoutSeconds", "Download timeout", "Deadline for one image download attempt. Native same-frame rereads retain their own bounded attempts.", "s", 1.0, Number {minimum:0.0,exclusive:true,maximum:3600.0}),
151 exposure_grace_seconds: f64 = 30.0 => ("exposureGraceSeconds", "Exposure grace", "Additional time beyond the requested exposure for the native worker to report a ready frame.", "s", 1.0, Number {minimum:0.0,exclusive:true,maximum:3600.0}),
152 cooling_timeout_seconds: f64 = 300.0 => ("coolingTimeoutSeconds", "Cooling recovery timeout", "Maximum thermal settling time after reconnecting an enabled cooler before a replacement exposure.", "s", 1.0, Number {minimum:0.0,exclusive:true,maximum:3600.0}),
153 temperature_tolerance_c: f64 = 2.0 => ("temperatureToleranceC", "Temperature tolerance", "Allowed temperature difference from the pre-failure reading while checking cooling recovery. Further cooling toward the setpoint is accepted.", "°C", 0.1, Number {minimum:0.0,exclusive:true,maximum:3600.0}),
154 cooling_stable_samples: u32 = 3 => ("coolingStableSamples", "Stable cooling samples", "Consecutive acceptable temperature and cooler-power observations required during recovery.", "samples", 1.0, Integer(1,60)),
155 cooling_sample_seconds: f64 = 2.0 => ("coolingSampleSeconds", "Cooling check interval", "Time between temperature and cooler-power checks while recovering a camera connection.", "s", 0.1, Number {minimum:0.0,exclusive:true,maximum:3600.0}),
156 ready_frame_download_retries: u32 = 2 => ("readyFrameDownloadRetries", "SDK ready-frame rereads", "Additional SDK download attempts only while the same exposure still reports a ready frame. No new exposure is started by a reread.", "retries", 1.0, Integer(0,5)),
157 direct_read_retries: u32 = 2 => ("directReadRetries", "Direct USB rereads", "Additional direct USB read attempts under the selected camera's capabilities. Retained-frame support is device-specific; this does not grant rereads to proxy cameras.", "retries", 1.0, Integer(0,5)),
158 direct_read_chunk_kib: u32 = 1024 => ("directReadChunkKiB", "Direct USB read size", "Maximum host USB read size in direct mode. Choose a power of two from 1 to 1024 KiB; the default is 1024. Smaller requests add overhead. SDK mode ignores this option. Separate from camera-side SDK USB Traffic; does not select USB speed or set a fixed MB/s cap.", "KiB", 1.0, PowerOfTwo(1,1024)),
159 transfer_log_mode: String = String::from("circular") => ("transferLogMode", "Transfer logging", "Circular (default): retain recent command/USB/API metadata in rotating diagnostic files and emit the history on a fault. Verbose: log every record. Off: keep failures and recovery summaries only. Image contents are never recorded. Applies while connected at the next safe worker checkpoint. REGAIN_CAMERA_TRANSFER_LOG=0 overrides recording.", "", 1.0, Choice(&["circular", "verbose", "off"])),
160 cooler_max_power_change_per_5_seconds: f64 = 25.0 => ("coolerMaxPowerChangePer5Seconds", "Direct cooler maximum power change", "Maximum power change in percentage points per five seconds, in either direction. Applies while connected at the next safe worker checkpoint, including regulation, enabling and setpoint changes. Recovery restores the last known output before resuming this limit. Cooler-off and loss of temperature feedback stop cooling immediately. SDK mode manages its own power and ignores this option.", "% / 5 s", 1.0, Number {minimum:1.0,exclusive:false,maximum:100.0}),
161 usb_reset_after_failures: u32 = 0 => ("usbResetAfterFailures", "USB reset threshold", "After this many recoverable failures, permit at most one USB reset per outage and capture. Connection failures persist across requests. Reset may restore a connection before exposure start regardless of exposure duration; it never permits replacing a long exposure. Zero disables it; two tries reconnecting first. Requires a verified camera target and OS permissions. On Windows, prepare the elevated helper before imaging; automatic recovery never requests elevation. Cycling camera USB may disconnect peripherals on its internal hub. External camera power is unchanged.", "failures", 1.0, Integer(0,20)),
162 usb_port_cycle: bool = false => ("usbPortCycle", "Linux USB port cycle", "On Linux, use a downstream port power cycle instead of USBDEVFS_RESET when configured USB recovery is triggered.", "", 1.0, Boolean),
163}