use crate::Failure;
use anyhow::{Result, ensure};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Clone, Copy)]
pub enum RecoveryType {
Integer(u32, u32),
PowerOfTwo(u32, u32),
Number {
minimum: f64,
exclusive: bool,
maximum: f64,
},
Boolean,
}
pub struct RecoveryField {
pub key: &'static str,
pub label: &'static str,
pub description: &'static str,
pub units: &'static str,
pub step: f64,
pub default: Value,
pub value_type: RecoveryType,
}
impl RecoveryField {
pub fn section(&self) -> &'static str {
match self.key {
"coolingTimeoutSeconds"
| "temperatureToleranceC"
| "coolingStableSamples"
| "coolingSampleSeconds" => "Cooling",
"commandTimeoutSeconds"
| "downloadTimeoutSeconds"
| "exposureGraceSeconds"
| "directReadChunkKiB" => "Timeouts",
_ => "Recovery",
}
}
pub fn accepts(&self, value: &Value) -> bool {
match self.value_type {
RecoveryType::Boolean => value.is_boolean(),
RecoveryType::PowerOfTwo(min, max) => value
.as_u64()
.is_some_and(|v| (min as u64..=max as u64).contains(&v) && v.is_power_of_two()),
RecoveryType::Integer(min, max) => value
.as_u64()
.is_some_and(|v| (min as u64..=max as u64).contains(&v)),
RecoveryType::Number {
minimum,
exclusive,
maximum,
} => value.as_f64().is_some_and(|v| {
v.is_finite() && v <= maximum && if exclusive { v > minimum } else { v >= minimum }
}),
}
}
pub fn schema(&self) -> Value {
let mut schema = json!({
"title":self.label,"description":self.description,"default":self.default,
"x-regain":{"key":self.key,"label":self.label,"description":self.description,
"group":"Native camera recovery","units":self.units,"step":self.step,
"apply":"reconnect","sensitive":false,"section":self.section(),
"platforms":if self.key == "usbPortCycle" {vec!["linux"]} else {vec!["windows","linux","macos"]}}
});
match self.value_type {
RecoveryType::Boolean => schema["type"] = json!("boolean"),
RecoveryType::PowerOfTwo(min, max) => {
schema["type"] = json!("integer");
schema["minimum"] = json!(min);
schema["maximum"] = json!(max);
schema["enum"] = json!(
(min..=max)
.filter(|v| v.is_power_of_two())
.collect::<Vec<_>>()
);
}
RecoveryType::Integer(min, max) => {
schema["type"] = json!("integer");
schema["minimum"] = json!(min);
schema["maximum"] = json!(max);
}
RecoveryType::Number {
minimum,
exclusive,
maximum,
} => {
schema["type"] = json!("number");
schema[if exclusive {
"exclusiveMinimum"
} else {
"minimum"
}] = json!(minimum);
schema["maximum"] = json!(maximum);
}
}
schema
}
}
macro_rules! recovery {
($($field:ident : $ty:ty = $default:expr => ($key:literal, $label:literal, $description:literal, $units:literal, $step:expr, $kind:expr)),+ $(,)?) => {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RecoveryOptions { $(#[serde(rename = $key)] pub $field: $ty),+ }
impl Default for RecoveryOptions {
fn default() -> Self { Self { $($field:$default),+ } }
}
impl RecoveryOptions {
pub fn fields() -> Vec<RecoveryField> {
vec![$(RecoveryField {key:$key,label:$label,description:$description,units:$units,step:$step,default:json!($default),value_type:$kind}),+]
}
pub fn validate(&self) -> Result<()> {
let values = serde_json::to_value(self)?;
for field in Self::fields() {
ensure!(field.accepts(&values[field.key]), Failure::Invalid(format!("Invalid camera recovery setting: {}",field.key)));
}
Ok(())
}
pub fn schema() -> Value {
let properties: serde_json::Map<String, Value> = Self::fields().into_iter().enumerate().map(|(order, field)| {
let mut schema = field.schema();
schema["x-regain"]["order"] = json!(order);
(field.key.into(),schema)
}).collect();
json!({"type":"object","additionalProperties":false,"default":Self::default(),"properties":properties})
}
}
}
}
use RecoveryType::{Boolean, Integer, Number, PowerOfTwo};
recovery! {
max_retries: u32 = 3 => ("maxRetries", "Replacement exposures", "Maximum replacement exposures after a recoverable failure. Zero disables replacement exposures.", "retries", 1.0, Integer(0,20)),
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}),
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}),
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}),
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}),
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}),
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}),
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}),
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)),
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}),
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)),
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)),
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)),
usb_reset_after_failures: u32 = 0 => ("usbResetAfterFailures", "USB reset threshold", "After this many recoverable failures, permit at most one USB reset per capture when a replacement exposure is allowed. Zero disables it; two tries reconnecting first. Requires a verified camera target and OS permissions. Windows may request administrator approval; external camera power is unchanged.", "failures", 1.0, Integer(0,20)),
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),
}