Skip to main content

regain_core/
recovery.rs

1//! Canonical native camera recovery configuration and frontend metadata.
2//! Legacy keys/defaults and acceptance ranges remain unchanged. Proxy cameras
3//! must not acquire these native recovery promises merely by republishing them.
4use 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            /// Strict hub/frontend schema without a schema/compiler dependency.
131            /// Legacy profile loading intentionally still tolerates unknown keys.
132            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}