regain-core 0.6.1

Camera worker supervision and capture recovery for PulsarFab regain
Documentation
//! Canonical native camera recovery configuration and frontend metadata.
//! Legacy keys/defaults and acceptance ranges remain unchanged. Proxy cameras
//! must not acquire these native recovery promises merely by republishing them.
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,
    Choice(&'static [&'static str]),
}
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"
            | "coolerMaxPowerChangePer5Seconds" => "Cooling",
            "commandTimeoutSeconds"
            | "downloadTimeoutSeconds"
            | "exposureGraceSeconds"
            | "directReadChunkKiB"
            | "transferLogMode" => "Timeouts",
            _ => "Recovery",
        }
    }
    pub fn accepts(&self, value: &Value) -> bool {
        match self.value_type {
            RecoveryType::Boolean => value.is_boolean(),
            RecoveryType::Choice(choices) => value.as_str().is_some_and(|v| choices.contains(&v)),
            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":if matches!(self.key, "transferLogMode" | "coolerMaxPowerChangePer5Seconds") {"live"} else {"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::Choice(choices) => {
                schema["type"] = json!("string");
                schema["enum"] = json!(choices);
            }
            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(())
            }
            /// Strict hub/frontend schema without a schema/compiler dependency.
            /// Legacy profile loading intentionally still tolerates unknown keys.
            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, Choice, 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)),
    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)),
    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)),
    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"])),
    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}),
    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)),
    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),
}