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 {
14        message: String,
15        code: Option<i32>,
16    },
17    /// A dispatched control has no known acknowledgement. Retire its worker;
18    /// retrying the command or capture could repeat an already applied write.
19    UncertainControl {
20        message: String,
21        code: Option<i32>,
22    },
23}
24impl std::fmt::Display for Failure {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::Invalid(m)
28            | Self::Worker { message: m, .. }
29            | Self::UncertainControl { message: m, .. } => f.write_str(m),
30            Self::Cancelled => f.write_str("Exposure aborted"),
31        }
32    }
33}
34impl std::error::Error for Failure {}
35pub fn invalid(message: impl Into<String>) -> anyhow::Error {
36    Failure::Invalid(message.into()).into()
37}
38pub fn retryable(error: &anyhow::Error) -> bool {
39    match error.downcast_ref::<Failure>() {
40        Some(Failure::Invalid(_) | Failure::Cancelled | Failure::UncertainControl { .. }) => false,
41        Some(Failure::Worker { code: Some(c), .. }) => {
42            matches!(c, 1 | 2 | 4 | 5 | 11 | 12 | 15 | 16)
43        }
44        _ => true,
45    }
46}
47#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
48pub struct Exposure {
49    pub width: u32,
50    pub height: u32,
51    pub bin: u32,
52    pub x: u32,
53    pub y: u32,
54    pub microseconds: u64,
55    pub dark: bool,
56}
57impl Exposure {
58    pub fn bytes(&self) -> Result<usize> {
59        let count = u64::from(self.width)
60            .checked_mul(u64::from(self.height))
61            .and_then(|pixels| pixels.checked_mul(2))
62            .ok_or_else(|| invalid("Invalid frame size"))?;
63        ensure!(
64            count > 0 && count <= 512 * 1024 * 1024,
65            Failure::Invalid("Invalid frame size".into())
66        );
67        Ok(count as usize)
68    }
69}
70#[derive(Clone, Debug, Serialize, Deserialize)]
71pub struct Control {
72    #[serde(rename = "type")]
73    pub kind: i32,
74    pub min: i64,
75    pub max: i64,
76    pub value: i64,
77    pub writable: bool,
78}
79pub use crate::recovery::RecoveryOptions;
80#[derive(Clone, Debug, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct Selection {
83    pub name: String,
84    pub serial: Option<String>,
85    pub direct: bool,
86    pub sdk_fallback: bool,
87    pub recovery: RecoveryOptions,
88}
89#[derive(Clone, Debug, Default, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct Status {
92    pub info: Value,
93    pub serial: Option<String>,
94    pub sdk_version: String,
95    pub backend: String,
96    pub sdk_fallback: bool,
97    pub phase: String,
98    pub error: Option<String>,
99    pub retry: RetryStatus,
100    pub controls: BTreeMap<i32, Control>,
101    pub values: BTreeMap<i32, i64>,
102    /// Acknowledged values, separate from queued desired settings. Monotonic
103    /// timestamps belong to this process and never enter the status JSON.
104    #[serde(skip)]
105    pub observations: BTreeMap<i32, ControlObservation>,
106    pub connected: bool,
107    pub control_connection_available: bool,
108    /// Matching frontend confirmation is required before a replacement starts.
109    /// Null unless the rebound camera is awaiting its peripherals.
110    pub usb_peripheral_recovery_id: Option<u64>,
111    pub sdk_exposure_state: Option<i64>,
112    pub sdk_error_code: Option<i32>,
113    pub process_id: Option<u32>,
114    pub white_balance_capabilities: Value,
115    pub white_balance: Option<crate::white_balance::Settings>,
116}
117/// Attempts made for the current/latest capture; the last failure survives
118/// successful recovery and subsequent healthy captures in this connection.
119#[derive(Clone, Debug, Default, Serialize)]
120#[serde(rename_all = "camelCase")]
121pub struct RetryStatus {
122    pub recaptures: u32,
123    pub downloads: u32,
124    pub usb_reads: u32,
125    pub last_failure: Option<String>,
126}
127impl Status {
128    pub fn recovery_info(&self) -> String {
129        let retry = &self.retry;
130        let failure = retry.last_failure.as_deref().unwrap_or("none");
131        let failure = failure.split_whitespace().collect::<Vec<_>>().join(" ");
132        format!(
133            "state: {}; retries: {} (recaptures: {}, downloads: {}, USB reads: {}); last failure: {}",
134            if self.phase.is_empty() {
135                "Disconnected"
136            } else {
137                &self.phase
138            },
139            u64::from(retry.recaptures) + u64::from(retry.downloads) + u64::from(retry.usb_reads),
140            retry.recaptures,
141            retry.downloads,
142            retry.usb_reads,
143            failure
144        )
145    }
146}
147pub type SharedStatus = Arc<Mutex<Status>>;
148/// Level, event, and message. Worker messages may be complete versioned
149/// `REGAIN_DIAGNOSTIC` records; forward those intact to preserve evidence.
150pub type Diagnostic = Arc<dyn Fn(&str, &str, &str) + Send + Sync>;
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
152pub struct ControlObservation {
153    pub value: i64,
154    pub observed_at: tokio::time::Instant,
155}
156/// Worker-relative age; never transfer a process-local monotonic timestamp.
157/// Receivers must also account for the request/response transit time.
158#[derive(Clone, Debug, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct ControlObservationReply {
161    pub value: i64,
162    pub age_seconds: f64,
163}
164impl ControlObservationReply {
165    pub fn normalize(&self, request_started: tokio::time::Instant) -> Result<ControlObservation> {
166        Ok(ControlObservation {
167            value: self.value,
168            observed_at: self.observed_at(request_started)?,
169        })
170    }
171    /// Conservatively include all IPC/worker time by subtracting the reported
172    /// age from request admission, never from response receipt. Process-local
173    /// monotonic clock epochs do not need to agree across the pipe.
174    pub fn observed_at(
175        &self,
176        request_started: tokio::time::Instant,
177    ) -> Result<tokio::time::Instant> {
178        let age = std::time::Duration::try_from_secs_f64(self.age_seconds)
179            .map_err(|_| invalid("Invalid control observation age"))?;
180        request_started
181            .checked_sub(age)
182            .ok_or_else(|| invalid("Unrepresentable control observation time"))
183    }
184}
185#[derive(Clone)]
186pub struct Frame {
187    pub exposure: Exposure,
188    pub pixels: Arc<[u8]>,
189    pub metadata: Value,
190}
191
192pub fn validate_exposure(
193    info: &Value,
194    controls: &BTreeMap<i32, Control>,
195    e: &Exposure,
196) -> Result<()> {
197    e.bytes()?;
198    let bins = info["bins"]
199        .as_array()
200        .ok_or_else(|| invalid("Camera binning is unavailable"))?;
201    let w = info["width"].as_u64().unwrap_or(0);
202    let h = info["height"].as_u64().unwrap_or(0);
203    let exp = controls
204        .get(&1)
205        .ok_or_else(|| invalid("Exposure control unavailable"))?;
206    let alignment = info["originAlignment"]
207        .as_u64()
208        .or_else(|| info["originAlignmentX"].as_u64())
209        .unwrap_or(1)
210        .max(1);
211    let align_y = info["originAlignmentY"]
212        .as_u64()
213        .unwrap_or(if info["originAlignment"].is_number() {
214            2
215        } else {
216            1
217        })
218        .max(1);
219    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) &&
220        (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 &&
221        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) &&
222        u64::from(e.x)*u64::from(e.bin)%alignment==0 && u64::from(e.y)*u64::from(e.bin)%align_y==0 &&
223        e.microseconds>0 && e.microseconds>=exp.min.max(0) as u64 && e.microseconds<=exp.max.max(0) as u64,
224        Failure::Invalid("Exposure or ROI is outside camera capabilities (width multiple of 8, height multiple of 2; see camera alignment)".into()));
225    Ok(())
226}
227
228/// Validate against the SDK's ROI rules when fallback is explicitly permitted.
229/// The worker's validate command decides whether it must switch before exposing.
230pub fn validate_capture(
231    info: &Value,
232    controls: &BTreeMap<i32, Control>,
233    e: &Exposure,
234    sdk_fallback: bool,
235) -> Result<()> {
236    if !sdk_fallback {
237        return validate_exposure(info, controls, e);
238    }
239    let mut info = info.clone();
240    info["minimumWidth"] = serde_json::json!(8);
241    info["minimumHeight"] = serde_json::json!(2);
242    info["originAlignment"] = serde_json::json!(1);
243    info["originAlignmentX"] = serde_json::json!(1);
244    info["originAlignmentY"] = serde_json::json!(1);
245    validate_exposure(&info, controls, e)
246}
247
248#[cfg(test)]
249mod observation_tests {
250    use super::*;
251    use std::time::Duration;
252    use tokio::time::Instant;
253
254    #[test]
255    fn worker_age_normalization_includes_transit_and_rejects_invalid_times() {
256        let request = Instant::now();
257        let reply = ControlObservationReply {
258            value: -100,
259            age_seconds: 20.25,
260        };
261        let observed = reply.observed_at(request).unwrap();
262        let received = request + Duration::from_secs(5);
263        assert_eq!(
264            received.duration_since(observed),
265            Duration::from_secs_f64(25.25)
266        );
267        for invalid_age in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX] {
268            assert!(
269                ControlObservationReply {
270                    value: 0,
271                    age_seconds: invalid_age
272                }
273                .observed_at(request)
274                .is_err()
275            );
276        }
277        assert_eq!(
278            ControlObservationReply {
279                value: 0,
280                age_seconds: 0.0
281            }
282            .observed_at(request)
283            .unwrap(),
284            request
285        );
286        for invalid in [
287            serde_json::json!({"value":true,"ageSeconds":0}),
288            serde_json::json!({"value":0,"ageSeconds":"0"}),
289            serde_json::json!({"value":0}),
290            serde_json::json!({"value":0,"ageSeconds":0,"timestamp":"private clock epoch"}),
291        ] {
292            assert!(serde_json::from_value::<ControlObservationReply>(invalid).is_err());
293        }
294    }
295}