Skip to main content

regain_core/
model.rs

1use anyhow::{Result, ensure};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::{
5    collections::BTreeMap,
6    sync::{Arc, Mutex},
7};
8
9#[derive(Debug)]
10pub enum Failure {
11    Invalid(String),
12    Cancelled,
13    Worker { message: String, code: Option<i32> },
14}
15impl std::fmt::Display for Failure {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            Self::Invalid(m) | Self::Worker { message: m, .. } => f.write_str(m),
19            Self::Cancelled => f.write_str("Exposure aborted"),
20        }
21    }
22}
23impl std::error::Error for Failure {}
24pub fn invalid(message: impl Into<String>) -> anyhow::Error {
25    Failure::Invalid(message.into()).into()
26}
27pub fn retryable(error: &anyhow::Error) -> bool {
28    match error.downcast_ref::<Failure>() {
29        Some(Failure::Invalid(_) | Failure::Cancelled) => false,
30        Some(Failure::Worker { code: Some(c), .. }) => {
31            matches!(c, 1 | 2 | 4 | 5 | 11 | 12 | 15 | 16)
32        }
33        _ => true,
34    }
35}
36#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
37pub struct Exposure {
38    pub width: u32,
39    pub height: u32,
40    pub bin: u32,
41    pub x: u32,
42    pub y: u32,
43    pub microseconds: u64,
44    pub dark: bool,
45}
46impl Exposure {
47    pub fn bytes(&self) -> Result<usize> {
48        let count = u64::from(self.width) * u64::from(self.height) * 2;
49        ensure!(
50            count > 0 && count <= 512 * 1024 * 1024,
51            Failure::Invalid("Invalid frame size".into())
52        );
53        Ok(count as usize)
54    }
55}
56#[derive(Clone, Debug, Serialize, Deserialize)]
57pub struct Control {
58    #[serde(rename = "type")]
59    pub kind: i32,
60    pub min: i64,
61    pub max: i64,
62    pub value: i64,
63    pub writable: bool,
64}
65#[derive(Clone, Debug, Serialize, Deserialize)]
66#[serde(default, rename_all = "camelCase")]
67pub struct RecoveryOptions {
68    pub max_retries: u32,
69    pub maximum_retry_exposure_seconds: f64,
70    pub reconnect_delay_seconds: f64,
71    pub command_timeout_seconds: f64,
72    pub download_timeout_seconds: f64,
73    pub exposure_grace_seconds: f64,
74    pub cooling_timeout_seconds: f64,
75    pub temperature_tolerance_c: f64,
76    pub cooling_stable_samples: u32,
77    pub cooling_sample_seconds: f64,
78    pub ready_frame_download_retries: u32,
79    pub direct_read_retries: u32,
80    /// Zero disables hardware recovery. At most one operation per capture.
81    pub usb_reset_after_failures: u32,
82    /// Linux: cycle the downstream port instead of USBDEVFS_RESET.
83    pub usb_port_cycle: bool,
84}
85impl Default for RecoveryOptions {
86    fn default() -> Self {
87        Self {
88            max_retries: 3,
89            maximum_retry_exposure_seconds: 30.,
90            reconnect_delay_seconds: 5.,
91            command_timeout_seconds: 15.,
92            download_timeout_seconds: 60.,
93            exposure_grace_seconds: 30.,
94            cooling_timeout_seconds: 300.,
95            temperature_tolerance_c: 2.,
96            cooling_stable_samples: 3,
97            cooling_sample_seconds: 2.,
98            ready_frame_download_retries: 2,
99            direct_read_retries: 2,
100            usb_reset_after_failures: 0,
101            usb_port_cycle: false,
102        }
103    }
104}
105impl RecoveryOptions {
106    pub fn validate(&self) -> Result<()> {
107        ensure!(
108            self.max_retries <= 20
109                && self.ready_frame_download_retries <= 5
110                && self.direct_read_retries <= 5
111                && self.usb_reset_after_failures <= 20
112                && (1..=60).contains(&self.cooling_stable_samples),
113            Failure::Invalid("Invalid retry limits".into())
114        );
115        ensure!(
116            self.maximum_retry_exposure_seconds.is_finite()
117                && (0.0..=86400.).contains(&self.maximum_retry_exposure_seconds),
118            Failure::Invalid("Invalid replacement exposure limit".into())
119        );
120        for v in [
121            self.reconnect_delay_seconds,
122            self.command_timeout_seconds,
123            self.download_timeout_seconds,
124            self.exposure_grace_seconds,
125            self.cooling_timeout_seconds,
126            self.temperature_tolerance_c,
127            self.cooling_sample_seconds,
128        ] {
129            ensure!(
130                v.is_finite() && v > 0. && v <= 3600.,
131                Failure::Invalid("Invalid recovery timeout or tolerance".into())
132            );
133        }
134        Ok(())
135    }
136}
137#[derive(Clone, Debug, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct Selection {
140    pub name: String,
141    pub serial: Option<String>,
142    pub direct: bool,
143    pub sdk_fallback: bool,
144    pub recovery: RecoveryOptions,
145}
146#[derive(Clone, Debug, Default, Serialize)]
147#[serde(rename_all = "camelCase")]
148pub struct Status {
149    pub info: Value,
150    pub serial: Option<String>,
151    pub sdk_version: String,
152    pub backend: String,
153    pub sdk_fallback: bool,
154    pub phase: String,
155    pub error: Option<String>,
156    pub controls: BTreeMap<i32, Control>,
157    pub values: BTreeMap<i32, i64>,
158    pub connected: bool,
159    pub control_connection_available: bool,
160    pub sdk_exposure_state: Option<i64>,
161    pub sdk_error_code: Option<i32>,
162    pub process_id: Option<u32>,
163    pub white_balance_capabilities: Value,
164    pub white_balance: Option<crate::white_balance::Settings>,
165}
166pub type SharedStatus = Arc<Mutex<Status>>;
167pub type Diagnostic = Arc<dyn Fn(&str, &str, &str) + Send + Sync>;
168#[derive(Clone)]
169pub struct Frame {
170    pub exposure: Exposure,
171    pub pixels: Arc<[u8]>,
172    pub metadata: Value,
173}
174
175pub fn validate_exposure(
176    info: &Value,
177    controls: &BTreeMap<i32, Control>,
178    e: &Exposure,
179) -> Result<()> {
180    e.bytes()?;
181    let bins = info["bins"]
182        .as_array()
183        .ok_or_else(|| invalid("Camera binning is unavailable"))?;
184    let w = info["width"].as_u64().unwrap_or(0);
185    let h = info["height"].as_u64().unwrap_or(0);
186    let exp = controls
187        .get(&1)
188        .ok_or_else(|| invalid("Exposure control unavailable"))?;
189    let alignment = info["originAlignment"]
190        .as_u64()
191        .or_else(|| info["originAlignmentX"].as_u64())
192        .unwrap_or(1)
193        .max(1);
194    let align_y = info["originAlignmentY"]
195        .as_u64()
196        .unwrap_or(if info["originAlignment"].is_number() {
197            2
198        } else {
199            1
200        })
201        .max(1);
202    ensure!(e.bin>0 && bins.iter().any(|b|b.as_u64()==Some(e.bin as u64)) && e.width.is_multiple_of(8) && e.height.is_multiple_of(2) &&
203        (u64::from(e.x)+u64::from(e.width))*u64::from(e.bin)<=w && (u64::from(e.y)+u64::from(e.height))*u64::from(e.bin)<=h &&
204        u64::from(e.width)*u64::from(e.bin)>=info["minimumWidth"].as_u64().unwrap_or(8) && u64::from(e.height)*u64::from(e.bin)>=info["minimumHeight"].as_u64().unwrap_or(2) &&
205        u64::from(e.x)*u64::from(e.bin)%alignment==0 && u64::from(e.y)*u64::from(e.bin)%align_y==0 &&
206        e.microseconds>0 && e.microseconds>=exp.min.max(0) as u64 && e.microseconds<=exp.max.max(0) as u64,
207        Failure::Invalid("Exposure or ROI is outside camera capabilities (width multiple of 8, height multiple of 2; see camera alignment)".into()));
208    Ok(())
209}
210
211/// Validate against the SDK's ROI rules when fallback is explicitly permitted.
212/// The worker's validate command decides whether it must switch before exposing.
213pub fn validate_capture(
214    info: &Value,
215    controls: &BTreeMap<i32, Control>,
216    e: &Exposure,
217    sdk_fallback: bool,
218) -> Result<()> {
219    if !sdk_fallback {
220        return validate_exposure(info, controls, e);
221    }
222    let mut info = info.clone();
223    info["minimumWidth"] = serde_json::json!(8);
224    info["minimumHeight"] = serde_json::json!(2);
225    info["originAlignment"] = serde_json::json!(1);
226    info["originAlignmentX"] = serde_json::json!(1);
227    info["originAlignmentY"] = serde_json::json!(1);
228    validate_exposure(&info, controls, e)
229}