Skip to main content

regain_core/
session.rs

1use crate::*;
2use anyhow::{Context, Result, ensure};
3use chrono::Utc;
4use serde_json::{Value, json};
5use std::{
6    collections::BTreeMap,
7    sync::{Arc, Mutex},
8    time::Duration,
9};
10use tokio::time::Instant;
11
12/// One owner per camera. All public entry points are serialized by the frontend.
13/// Status and queued controls remain readable while capture owns the worker.
14pub struct Session {
15    pub status: SharedStatus,
16    pub selection: Selection,
17    runtime: Runtime,
18    log: Diagnostic,
19    worker: Option<Worker>,
20    direct: bool,
21    ever_opened: bool,
22    applied: BTreeMap<i32, i64>,
23    recovery_temperature: Option<f64>,
24    recovery_power: Option<i64>,
25    settle_required: bool,
26    usb_target: Option<String>,
27}
28impl Session {
29    pub fn new(selection: Selection, runtime: Runtime, log: Diagnostic) -> Result<Self> {
30        selection.recovery.validate()?;
31        Ok(Self {
32            status: Arc::new(Mutex::new(Status::default())),
33            direct: selection.direct,
34            selection,
35            runtime,
36            log,
37            worker: None,
38            ever_opened: false,
39            applied: BTreeMap::new(),
40            recovery_temperature: None,
41            recovery_power: None,
42            settle_required: false,
43            usb_target: None,
44        })
45    }
46    fn emit(&self, level: &str, event: &str, message: impl AsRef<str>) {
47        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
48            (self.log)(level, event, message.as_ref())
49        }));
50    }
51    fn phase(&self, phase: impl Into<String>) {
52        let phase = phase.into();
53        self.status.lock().unwrap().phase = phase.clone();
54        self.emit(
55            if matches!(
56                phase.as_str(),
57                "Idle" | "Starting exposure" | "Exposing" | "Downloading"
58            ) {
59                "debug"
60            } else {
61                "info"
62            },
63            "session.phase",
64            phase,
65        );
66    }
67    pub fn snapshot(&self) -> Status {
68        self.status.lock().unwrap().clone()
69    }
70    /// Opt into Regain-owned WB. The caller serializes this with capture, like
71    /// all Session operations. Settings and effective AWB gains survive recovery.
72    pub async fn set_white_balance(
73        &mut self,
74        settings: crate::white_balance::Settings,
75        token: &CancellationToken,
76    ) -> Result<()> {
77        settings.gains.validate()?;
78        let result = self
79            .call(
80                "white-balance",
81                serde_json::to_value(settings)?,
82                None,
83                token,
84            )
85            .await?
86            .0;
87        self.status.lock().unwrap().white_balance =
88            Some(serde_json::from_value(result["settings"].clone())?);
89        // Legacy WB/flip controls must not be replayed over managed WB.
90        for kind in [3, 4, 9] {
91            self.applied.remove(&kind);
92        }
93        Ok(())
94    }
95    pub async fn simulate_read_failures(
96        &mut self,
97        count: u32,
98        token: &CancellationToken,
99    ) -> Result<()> {
100        ensure!(
101            self.runtime.simulate && self.direct,
102            "Read fault injection requires a simulated direct camera"
103        );
104        self.call(
105            "simulate-read-failures",
106            json!({"count":count}),
107            None,
108            token,
109        )
110        .await?;
111        Ok(())
112    }
113    pub fn seed_recovery(&mut self, temperature: Option<f64>, power: Option<i64>) {
114        self.recovery_temperature = temperature.filter(|t| t.is_finite());
115        self.recovery_power = power;
116        self.settle_required = true;
117    }
118    pub fn queue_control(status: &SharedStatus, kind: i32, value: i64) -> Result<()> {
119        let mut state = status.lock().unwrap();
120        ensure!(
121            state.white_balance.is_none() || !matches!(kind, 3 | 4 | 9),
122            Failure::Invalid("WB and flip controls are owned by managed white balance".into())
123        );
124        let cap = state
125            .controls
126            .get(&kind)
127            .ok_or_else(|| invalid(format!("Control {kind} unavailable")))?;
128        ensure!(
129            cap.writable && value >= cap.min && value <= cap.max,
130            Failure::Invalid(format!(
131                "Control {kind} must be writable and between {} and {}",
132                cap.min, cap.max
133            ))
134        );
135        ensure!(
136            matches!(
137                kind,
138                0 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23
139            ),
140            Failure::Invalid("Control is not a persistent imaging setting".into())
141        );
142        state.values.insert(kind, value);
143        Ok(())
144    }
145    async fn call(
146        &mut self,
147        method: &str,
148        params: Value,
149        seconds: Option<f64>,
150        token: &CancellationToken,
151    ) -> Result<(Value, Vec<u8>)> {
152        self.worker
153            .as_mut()
154            .context("Camera worker is disconnected")?
155            .call(
156                method,
157                params,
158                seconds.unwrap_or(self.selection.recovery.command_timeout_seconds),
159                token,
160            )
161            .await
162    }
163    async fn delay(&self, seconds: f64, token: &CancellationToken) -> Result<()> {
164        tokio::select! {biased;_=token.cancelled()=>Err(Failure::Cancelled.into()),_=tokio::time::sleep(Duration::from_secs_f64(seconds))=>Ok(())}
165    }
166    async fn invalidate(&mut self) {
167        if self.ever_opened && !self.settle_required {
168            let state = self.snapshot();
169            self.recovery_temperature = state.values.get(&8).map(|v| *v as f64 / 10.);
170            self.recovery_power = state.values.get(&15).copied();
171            self.settle_required = true;
172        }
173        self.status.lock().unwrap().control_connection_available = false;
174        if let Some(mut worker) = self.worker.take() {
175            worker.kill().await;
176        }
177    }
178    async fn fallback(&mut self, reason: &str, token: &CancellationToken) -> Result<()> {
179        self.emit(
180            "warning",
181            "backend.fallback",
182            format!("Switching to SDK fallback: {reason}"),
183        );
184        self.invalidate().await;
185        self.direct = false;
186        self.phase("SDK fallback reconnect delay");
187        self.delay(self.selection.recovery.reconnect_delay_seconds, token)
188            .await
189    }
190    pub async fn connect(&mut self, token: &CancellationToken) -> Result<()> {
191        let result = async {
192            match self.open(token).await {
193                Err(error) if self.direct && self.selection.sdk_fallback && retryable(&error) => {
194                    self.fallback(&error.to_string(), token).await?;
195                    self.open(token).await
196                }
197                other => other,
198            }?;
199            if self.selection.recovery.usb_reset_after_failures > 0 && self.usb_target.is_none() {
200                // First connection can learn the serial. Release its handle before
201                // binding the physical device, then reopen that exact serial.
202                self.invalidate().await;
203                self.bind_usb(token).await?;
204                self.open(token).await?;
205            }
206            self.ever_opened = true;
207            self.status.lock().unwrap().connected = true;
208            self.phase("Idle");
209            Ok(())
210        }
211        .await;
212        if let Err(error) = &result {
213            self.emit("warning", "connection.failed", format!("{error:#}"));
214            self.invalidate().await;
215        }
216        result
217    }
218    async fn bind_usb(&mut self, token: &CancellationToken) -> Result<()> {
219        if token.is_cancelled() {
220            return Err(Failure::Cancelled.into());
221        }
222        if self.runtime.simulate {
223            self.usb_target = Some("simulation".into());
224            return Ok(());
225        }
226        let serial = self
227            .selection
228            .serial
229            .as_deref()
230            .context("USB recovery requires a camera serial")?;
231        let args = [
232            "zwo",
233            "camera-direct",
234            "--usb-target",
235            &self.selection.name,
236            serial,
237        ];
238        let command = self.runtime.usb_command(&args, 30);
239        let encoded = tokio::select! { biased; _=token.cancelled()=>return Err(Failure::Cancelled.into()), r=command=>r? };
240        let target = regain_transport::usb::Target::decode(&encoded)?;
241        ensure!(
242            target.serial.eq_ignore_ascii_case(serial),
243            "USB recovery serial mismatch"
244        );
245        self.usb_target = Some(encoded);
246        self.emit(
247            "info",
248            "usb.bound",
249            "USB recovery bound to the selected camera's serial and physical location",
250        );
251        Ok(())
252    }
253    async fn reset_usb(&mut self, token: &CancellationToken) -> Result<()> {
254        if token.is_cancelled() {
255            return Err(Failure::Cancelled.into());
256        }
257        let target = self
258            .usb_target
259            .as_deref()
260            .context("No verified USB recovery target")?;
261        ensure!(
262            self.worker.is_none(),
263            "Close the camera worker before USB recovery"
264        );
265        self.phase("USB recovery");
266        self.emit(
267            "warning",
268            "usb.reset",
269            "Resetting the selected camera; the retained frame is abandoned",
270        );
271        if !self.runtime.simulate {
272            // Once dispatched, finish the bounded helper before honoring abort so
273            // a Linux port cycle can always re-enable the port.
274            self.runtime
275                .usb_command(
276                    &[
277                        "usb",
278                        if self.selection.recovery.usb_port_cycle {
279                            "cycle"
280                        } else {
281                            "reset"
282                        },
283                        target,
284                    ],
285                    80,
286                )
287                .await?;
288        }
289        self.usb_target = None;
290        if token.is_cancelled() {
291            return Err(Failure::Cancelled.into());
292        }
293        self.phase("Waiting for USB camera");
294        let deadline = Instant::now() + Duration::from_secs(30);
295        loop {
296            match self.bind_usb(token).await {
297                Ok(()) => break,
298                Err(e) if token.is_cancelled() || Instant::now() >= deadline => return Err(e),
299                Err(_) => self.delay(0.5, token).await?,
300            }
301        }
302        self.emit(
303            "info",
304            "usb.returned",
305            "The same camera serial is available after USB recovery",
306        );
307        Ok(())
308    }
309    async fn open(&mut self, token: &CancellationToken) -> Result<()> {
310        if self.ever_opened && self.selection.serial.is_none() {
311            return Err(invalid(
312                "Automatic recovery requires a camera serial number",
313            ));
314        }
315        self.phase("Opening");
316        self.worker = Some(self.runtime.spawn(self.direct, self.log.clone()).await?);
317        self.applied.clear();
318        let (result, _) = self
319            .call(
320                "open",
321                json!({"name":self.selection.name,"serial":self.selection.serial}),
322                None,
323                token,
324            )
325            .await?;
326        let serial = result["serial"].as_str().map(str::to_owned);
327        ensure!(
328            self.selection.serial.is_none() || self.selection.serial == serial,
329            Failure::Invalid("Camera identity changed".into())
330        );
331        let info = &result["info"];
332        ensure!(
333            info["name"] == self.selection.name
334                && info["formats"]
335                    .as_array()
336                    .is_some_and(|a| a.contains(&json!(2))),
337            Failure::Invalid("Camera identity or RAW16 support changed".into())
338        );
339        let previous = self.snapshot();
340        ensure!(
341            !self.ever_opened
342                || (previous.info["width"] == info["width"]
343                    && previous.info["height"] == info["height"]),
344            Failure::Invalid("Camera geometry changed".into())
345        );
346        let mut controls: Vec<Control> = serde_json::from_value(result["controls"].clone())
347            .map_err(|_| invalid("Invalid camera controls"))?;
348        if self.direct
349            && self.selection.sdk_fallback
350            && matches!(
351                self.selection.name.as_str(),
352                "ZWO ASI2600MM Duo" | "ZWO ASI220MM Mini"
353            )
354            && let Some(exp) = controls.iter_mut().find(|c| c.kind == 1)
355        {
356            exp.max = 2_000_000_000;
357        }
358        ensure!(
359            controls.iter().any(|c| c.kind == 1),
360            Failure::Invalid("Exposure control unavailable".into())
361        );
362        if info["cooled"] == true {
363            ensure!(
364                [8, 16, 17]
365                    .iter()
366                    .all(|k| controls.iter().any(|c| c.kind == *k)),
367                Failure::Invalid("Required cooling controls unavailable".into())
368            );
369        }
370        self.selection.serial = serial.clone();
371        {
372            let mut state = self.status.lock().unwrap();
373            state.info = info.clone();
374            state.serial = serial;
375            state.sdk_version = result["sdkVersion"].as_str().unwrap_or("unknown").into();
376            state.backend = if self.direct { "direct" } else { "sdk" }.into();
377            state.sdk_fallback = self.selection.direct && !self.direct;
378            state.controls = controls.into_iter().map(|c| (c.kind, c)).collect();
379            for c in state.controls.values().cloned().collect::<Vec<_>>() {
380                if (c.writable || c.kind == 6)
381                    && matches!(
382                        c.kind,
383                        0 | 2
384                            | 3
385                            | 4
386                            | 5
387                            | 6
388                            | 7
389                            | 9
390                            | 13
391                            | 14
392                            | 16
393                            | 17
394                            | 18
395                            | 19
396                            | 20
397                            | 21
398                            | 22
399                            | 23
400                    )
401                {
402                    state.values.entry(c.kind).or_insert(c.value);
403                } else {
404                    state.values.insert(c.kind, c.value);
405                }
406            }
407            state.control_connection_available = true;
408            state.process_id = self.worker.as_ref().and_then(Worker::pid);
409            state.white_balance_capabilities = result["whiteBalance"].clone();
410        }
411        if let Some(settings) = previous.white_balance {
412            // A new worker has no previous estimate for Locked to freeze.
413            if settings.mode == crate::white_balance::Mode::Locked {
414                self.set_white_balance(
415                    crate::white_balance::Settings {
416                        mode: crate::white_balance::Mode::Manual,
417                        ..settings
418                    },
419                    token,
420                )
421                .await?;
422            }
423            self.set_white_balance(settings, token).await?;
424        }
425        self.emit(
426            "info",
427            "connection.opened",
428            format!(
429                "Camera opened using {}{}; serial {}",
430                if self.direct { "direct" } else { "SDK" },
431                if self.selection.direct && !self.direct {
432                    " fallback"
433                } else {
434                    ""
435                },
436                self.selection.serial.as_deref().unwrap_or("unavailable")
437            ),
438        );
439        Ok(())
440    }
441    fn settings(&self) -> BTreeMap<i32, i64> {
442        let state = self.snapshot();
443        state
444            .values
445            .into_iter()
446            .filter(|(k, _)| {
447                (state.white_balance.is_none() || !matches!(k, 3 | 4 | 9))
448                    && state.controls.get(k).is_some_and(|c| c.writable || *k == 6)
449                    && matches!(
450                        k,
451                        0 | 2
452                            | 3
453                            | 4
454                            | 5
455                            | 6
456                            | 7
457                            | 9
458                            | 13
459                            | 14
460                            | 16
461                            | 17
462                            | 18
463                            | 19
464                            | 20
465                            | 21
466                            | 22
467                            | 23
468                    )
469            })
470            .collect()
471    }
472    async fn apply(
473        &mut self,
474        values: &mut BTreeMap<i32, i64>,
475        token: &CancellationToken,
476    ) -> Result<()> {
477        let mut ordered: Vec<_> = values.iter().map(|(k, v)| (*k, *v)).collect();
478        ordered.sort_by_key(|(k, _)| if *k == 17 { 100 } else { *k });
479        for (kind, value) in ordered {
480            if self.snapshot().white_balance.is_some() && matches!(kind, 3 | 4 | 9) {
481                continue;
482            }
483            if self.applied.get(&kind) == Some(&value) {
484                continue;
485            }
486            let state = self.snapshot();
487            let cap = state
488                .controls
489                .get(&kind)
490                .ok_or_else(|| invalid(format!("Control {kind} disappeared after reconnect")))?;
491            if !cap.writable {
492                ensure!(
493                    cap.value == value,
494                    Failure::Invalid(format!("Read-only control {kind} changed"))
495                );
496                self.applied.insert(kind, value);
497                continue;
498            }
499            self.call("set", json!({"control":kind,"value":value}), None, token)
500                .await?;
501            let actual = self
502                .call("get", json!({"control":kind}), None, token)
503                .await?
504                .0
505                .as_i64()
506                .ok_or_else(|| invalid("Invalid control readback"))?;
507            if actual != value {
508                ensure!(
509                    !self.direct && kind == 5 && actual >= cap.min && actual <= cap.max,
510                    "Control {kind} readback {actual} differs from requested {value}"
511                );
512                self.emit(
513                    "info",
514                    "control.clamped",
515                    format!("SDK applied offset {actual} instead of {value}"),
516                );
517                values.insert(kind, actual);
518                let mut shared = self.status.lock().unwrap();
519                if shared.values.get(&kind) == Some(&value) {
520                    shared.values.insert(kind, actual);
521                }
522            }
523            self.applied.insert(kind, actual);
524        }
525        Ok(())
526    }
527    async fn read_environment(
528        &mut self,
529        token: &CancellationToken,
530    ) -> Result<(Option<f64>, Option<i64>)> {
531        for kind in [8, 15] {
532            if self.snapshot().controls.contains_key(&kind) {
533                let value = self
534                    .call("get", json!({"control":kind}), None, token)
535                    .await?
536                    .0
537                    .as_i64()
538                    .ok_or_else(|| invalid("Invalid environment value"))?;
539                self.status.lock().unwrap().values.insert(kind, value);
540            }
541        }
542        let state = self.snapshot();
543        Ok((
544            state.values.get(&8).map(|v| *v as f64 / 10.),
545            state.values.get(&15).copied(),
546        ))
547    }
548    pub async fn refresh(&mut self, token: &CancellationToken) -> Result<()> {
549        let result = async {
550            if self.worker.is_none() {
551                self.phase("Restoring camera controls");
552                self.delay(self.selection.recovery.reconnect_delay_seconds, token)
553                    .await?;
554                self.open(token).await?;
555            }
556            self.apply(&mut self.settings(), token).await?;
557            self.read_environment(token).await?;
558            Ok(())
559        }
560        .await;
561        if let Err(error) = &result {
562            self.emit("warning", "controls.failed", format!("{error:#}"));
563            self.invalidate().await;
564        }
565        result
566    }
567    pub fn ready_timeout(&self, seconds: f64) -> f64 {
568        let o = &self.selection.recovery;
569        seconds
570            + o.exposure_grace_seconds
571            + if self.direct {
572                (1 + if self.retained() || seconds <= o.maximum_retry_exposure_seconds {
573                    o.direct_read_retries
574                } else {
575                    0
576                }) as f64
577                    * o.download_timeout_seconds
578                    + o.direct_read_retries as f64
579                        * self.snapshot().info["readRetryOverheadSeconds"]
580                            .as_f64()
581                            .unwrap_or(0.)
582                            .clamp(0., 15.)
583            } else {
584                0.
585            }
586    }
587    fn retained(&self) -> bool {
588        self.direct && self.snapshot().info["retainedFrameReads"] == true
589    }
590    pub async fn capture(&mut self, e: Exposure, token: &CancellationToken) -> Result<Frame> {
591        if self.snapshot().white_balance.is_some() {
592            let state = self.snapshot();
593            crate::white_balance::WhiteBalance::validate_geometry(
594                state.info["color"] == true,
595                state.info["bayer"].as_u64(),
596                e.bin,
597            )
598            .map_err(|error| invalid(error.to_string()))?;
599        }
600        let state = self.snapshot();
601        validate_capture(
602            &state.info,
603            &state.controls,
604            &e,
605            self.direct && self.selection.sdk_fallback,
606        )?;
607        let options = self.selection.recovery.clone();
608        let seconds = e.microseconds as f64 / 1e6;
609        let retries = if seconds <= options.maximum_retry_exposure_seconds {
610            options.max_retries
611        } else {
612            0
613        };
614        let mut settings = self.settings();
615        let mut prior = self
616            .recovery_temperature
617            .or_else(|| state.values.get(&8).map(|v| *v as f64 / 10.));
618        let mut power = self
619            .recovery_power
620            .or_else(|| state.values.get(&15).copied());
621        let mut last = None;
622        let mut usb_resets = 0;
623        for attempt in 0..=retries {
624            let result = self
625                .attempt(&e, &mut settings, &mut prior, &mut power, attempt, token)
626                .await;
627            match result {
628                Ok(mut frame) => {
629                    frame.metadata["recoveries"] = json!(attempt);
630                    frame.metadata["usbResets"] = json!(usb_resets);
631                    self.phase("Idle");
632                    return Ok(frame);
633                }
634                Err(error) => {
635                    {
636                        let mut state = self.status.lock().unwrap();
637                        state.error = Some(format!("{error:#}"));
638                        state.sdk_error_code = match error.downcast_ref::<Failure>() {
639                            Some(Failure::Worker { code, .. }) => *code,
640                            _ => None,
641                        };
642                    }
643                    let can_retry = retryable(&error) && attempt < retries && !token.is_cancelled();
644                    self.emit(
645                        "warning",
646                        "capture.failed",
647                        format!("Attempt {}/{}: {error:#}", attempt + 1, retries + 1),
648                    );
649                    self.invalidate().await;
650                    if !can_retry {
651                        last = Some(error);
652                        break;
653                    }
654                    if usb_resets == 0
655                        && options.usb_reset_after_failures > 0
656                        && attempt + 1 >= options.usb_reset_after_failures
657                    {
658                        usb_resets += 1;
659                        if let Err(e) = self.reset_usb(token).await {
660                            self.emit("error", "usb.failed", format!("{e:#}"));
661                            last = Some(e);
662                            break;
663                        }
664                    }
665                    if self.direct && self.selection.sdk_fallback {
666                        self.direct = false;
667                        self.emit(
668                            "warning",
669                            "backend.fallback",
670                            "Next permitted exposure retry will use SDK fallback",
671                        );
672                    }
673                    self.emit("warning","capture.retry",format!("Scheduling replacement exposure {}/{}: {seconds} s, {}x{}, bin {}; reconnect delay {} s",attempt+1,retries,e.width,e.height,e.bin,options.reconnect_delay_seconds));
674                    last = Some(error);
675                }
676            }
677        }
678        self.phase(if token.is_cancelled() {
679            "Aborted"
680        } else {
681            "Error"
682        });
683        Err(last.unwrap_or_else(|| invalid("Capture failed")))
684    }
685    async fn attempt(
686        &mut self,
687        e: &Exposure,
688        settings: &mut BTreeMap<i32, i64>,
689        prior: &mut Option<f64>,
690        power: &mut Option<i64>,
691        attempt: u32,
692        token: &CancellationToken,
693    ) -> Result<Frame> {
694        let options = self.selection.recovery.clone();
695        if token.is_cancelled() {
696            return Err(Failure::Cancelled.into());
697        }
698        if attempt > 0 || self.worker.is_none() {
699            self.phase("Reconnect delay");
700            self.invalidate().await;
701            self.delay(options.reconnect_delay_seconds, token).await?;
702            self.open(token).await?;
703        }
704        self.apply(settings, token).await?;
705        if self.settle_required
706            && settings.get(&17).copied().unwrap_or(0) != 0
707            && let Some(t) = *prior
708        {
709            self.settle(t, *power, *settings.get(&16).unwrap_or(&0) as f64, token)
710                .await?;
711        }
712        let observed = self.read_environment(token).await?;
713        *prior = observed.0.or(*prior);
714        *power = observed.1.or(*power);
715        if self.direct && self.selection.sdk_fallback {
716            let result = self
717                .call("validate", serde_json::to_value(e)?, None, token)
718                .await;
719            if let Err(error) = result {
720                if matches!(
721                    error.downcast_ref::<Failure>(),
722                    Some(Failure::Worker { code: Some(8), .. })
723                ) {
724                    self.fallback(&error.to_string(), token).await?;
725                    self.open(token).await?;
726                    self.apply(settings, token).await?;
727                    if settings.get(&17).copied().unwrap_or(0) != 0
728                        && let Some(t) = *prior
729                    {
730                        self.settle(t, *power, *settings.get(&16).unwrap_or(&0) as f64, token)
731                            .await?;
732                    }
733                } else {
734                    return Err(error);
735                }
736            }
737        }
738        self.settle_required = false;
739        self.recovery_temperature = None;
740        self.recovery_power = None;
741        self.phase("Starting exposure");
742        let started = Utc::now();
743        let seconds = e.microseconds as f64 / 1e6;
744        let mut params = serde_json::to_value(e)?;
745        if self.direct {
746            params["readRetries"] = json!(if self.retained()
747                || seconds <= options.maximum_retry_exposure_seconds
748            {
749                options.direct_read_retries
750            } else {
751                0
752            });
753            params["captureTimeoutSeconds"] =
754                json!(self.ready_timeout(seconds) + options.command_timeout_seconds);
755            params["transferTimeoutSeconds"] = json!(options.download_timeout_seconds);
756        }
757        self.call("start", params, None, token).await?;
758        self.phase("Exposing");
759        let clock = Instant::now();
760        let mut environment_sample = Instant::now();
761        loop {
762            let state = self
763                .call("status", Value::Null, None, token)
764                .await?
765                .0
766                .as_i64()
767                .ok_or_else(|| invalid("Invalid exposure state"))?;
768            self.status.lock().unwrap().sdk_exposure_state = Some(state);
769            if state == 2 {
770                break;
771            }
772            ensure!(state == 1, "Exposure ended in camera state {state}");
773            ensure!(
774                clock.elapsed().as_secs_f64() <= self.ready_timeout(seconds),
775                "Exposure readiness timed out"
776            );
777            if environment_sample.elapsed() >= Duration::from_secs(2) {
778                self.read_environment(token).await?;
779                environment_sample = Instant::now();
780            }
781            self.delay(0.025, token).await?;
782        }
783        self.phase("Downloading");
784        let mut reads = 0;
785        let (mut metadata, pixels) = loop {
786            match self
787                .call(
788                    "download",
789                    Value::Null,
790                    Some(options.download_timeout_seconds),
791                    token,
792                )
793                .await
794            {
795                Ok(frame) => break frame,
796                Err(error) if !self.retained() && retryable(&error) && !token.is_cancelled() => {
797                    self.emit("warning", "transfer.failed", format!("{error:#}"));
798                    let state = self
799                        .call("status", Value::Null, None, token)
800                        .await?
801                        .0
802                        .as_i64()
803                        .ok_or_else(|| invalid("Invalid post-transfer state"))?;
804                    self.status.lock().unwrap().sdk_exposure_state = Some(state);
805                    if reads >= options.ready_frame_download_retries || state != 2 {
806                        return Err(error);
807                    }
808                    reads += 1;
809                    self.phase(format!(
810                        "Rereading ready frame ({reads}/{})",
811                        options.ready_frame_download_retries
812                    ));
813                    self.delay(options.reconnect_delay_seconds, token).await?;
814                }
815                Err(error) => return Err(error),
816            }
817        };
818        ensure!(
819            pixels.len() == e.bytes()?,
820            Failure::Invalid("Image length differs from requested ROI".into())
821        );
822        for (key, expected) in [("width", e.width), ("height", e.height)] {
823            ensure!(
824                metadata[key].as_u64() == Some(expected as u64),
825                Failure::Invalid(format!("Frame {key} differs from request"))
826            );
827        }
828        if let Some(error) = metadata["cleanupError"].as_str() {
829            self.emit(
830                "warning",
831                "capture.cleanup_failed",
832                format!("Frame preserved; reconnect required: {error}"),
833            );
834            self.invalidate().await;
835        }
836        metadata["startedUtc"] = json!(started);
837        if self.snapshot().white_balance.is_some() {
838            let settings = serde_json::from_value(metadata["whiteBalance"]["settings"].clone())
839                .map_err(|_| invalid("Missing or invalid managed white balance frame metadata"))?;
840            self.status.lock().unwrap().white_balance = Some(settings);
841        }
842        metadata["endedUtc"] = json!(Utc::now());
843        metadata["exposure"] = serde_json::to_value(e)?;
844        metadata["controls"] = serde_json::to_value(settings)?;
845        metadata["backend"] = json!(if self.direct { "direct" } else { "sdk" });
846        metadata["sdkFallback"] = json!(self.selection.direct && !self.direct);
847        metadata["downloadRetries"] = json!(reads);
848        if attempt > 0 || reads > 0 || metadata["readRecoveries"].as_u64().unwrap_or(0) > 0 {
849            self.emit("info","capture.recovered",format!("Returning {}x{} image after {attempt} replacement exposures, {reads} SDK read retries, {} retained-frame retries and {} handle reopens",e.width,e.height,
850                metadata["readRecoveries"].as_u64().unwrap_or(0), metadata["handleReopens"].as_u64().unwrap_or(0)));
851        }
852        Ok(Frame {
853            exposure: e.clone(),
854            metadata,
855            pixels: pixels.into(),
856        })
857    }
858    async fn settle(
859        &mut self,
860        prior: f64,
861        prior_power: Option<i64>,
862        target: f64,
863        token: &CancellationToken,
864    ) -> Result<()> {
865        let o = self.selection.recovery.clone();
866        let clock = Instant::now();
867        let mut stable = 0;
868        let mut hold = CoolingHold::default();
869        self.phase(format!("Restoring cooling near {prior:.1} C"));
870        while clock.elapsed().as_secs_f64() < o.cooling_timeout_seconds {
871            let (temperature, power) = self.read_environment(token).await?;
872            let output = prior_power.is_none_or(|p| p <= 10 || power.is_some_and(|v| v >= p - 10));
873            let near = temperature.is_some_and(|t| {
874                t >= prior.min(target) - o.temperature_tolerance_c
875                    && t <= prior + o.temperature_tolerance_c
876            });
877            let target_held = hold.observe(
878                temperature,
879                power,
880                target,
881                o.temperature_tolerance_c,
882                o.cooling_sample_seconds,
883                clock.elapsed().as_secs_f64(),
884            );
885            stable = if (near && output) || target_held {
886                stable + 1
887            } else {
888                0
889            };
890            self.emit("info","cooling.wait",format!("Temperature {temperature:?} C (prior {prior}), power {power:?}% (prior {prior_power:?}%), stable {stable}/{}",o.cooling_stable_samples));
891            if stable >= o.cooling_stable_samples {
892                self.emit(
893                    "info",
894                    "cooling.recovered",
895                    format!("Cooling recovered at {temperature:?} C; restored setpoint {target} C"),
896                );
897                return Ok(());
898            }
899            self.delay(o.cooling_sample_seconds, token).await?;
900        }
901        anyhow::bail!("Camera did not recover its prior cooling temperature and output")
902    }
903    pub async fn close(&mut self) {
904        let token = CancellationToken::new();
905        let closed = if self.worker.is_some() {
906            match self.call("close", Value::Null, Some(2.), &token).await {
907                Ok(_) => true,
908                Err(error) => {
909                    let message = format!("Camera close or settings restoration failed: {error:#}");
910                    self.emit("warning", "camera.cleanup_failed", &message);
911                    self.status.lock().unwrap().error = Some(message);
912                    false
913                }
914            }
915        } else {
916            false
917        };
918        self.invalidate().await;
919        if !closed
920            && self.ever_opened
921            && self.direct
922            && self.snapshot().info["cooled"] == true
923            && self.selection.serial.is_some()
924        {
925            let cleanup = async {
926                self.delay(self.selection.recovery.reconnect_delay_seconds, &token)
927                    .await?;
928                self.open(&token).await?;
929                self.call("set", json!({"control":17,"value":0}), None, &token)
930                    .await?;
931                self.call("close", Value::Null, Some(2.), &token).await?;
932                Result::<()>::Ok(())
933            }
934            .await;
935            if let Err(error) = cleanup {
936                self.emit(
937                    "warning",
938                    "cooling.cleanup_failed",
939                    format!("Could not disable cooling: {error:#}"),
940                );
941            }
942            self.invalidate().await;
943        }
944        self.status.lock().unwrap().connected = false;
945        self.usb_target = None;
946        self.phase("Disconnected");
947    }
948}
949
950#[derive(Default)]
951pub struct CoolingHold {
952    since: Option<f64>,
953    first: f64,
954    last: Option<f64>,
955}
956impl CoolingHold {
957    pub fn observe(
958        &mut self,
959        temperature: Option<f64>,
960        power: Option<i64>,
961        target: f64,
962        tolerance: f64,
963        sample: f64,
964        elapsed: f64,
965    ) -> bool {
966        let valid = temperature
967            .is_some_and(|t| t.is_finite() && (t - target).abs() <= tolerance.min(1.))
968            && power.is_some_and(|p| p > 0);
969        if !valid {
970            self.since = None;
971            self.last = None;
972            return false;
973        }
974        let t = temperature.unwrap();
975        if self.since.is_none()
976            || self
977                .last
978                .is_some_and(|last| elapsed < last || elapsed - last > 5f64.max(sample * 2.))
979            || t > self.first + 0.2
980        {
981            self.since = Some(elapsed);
982            self.first = t;
983        }
984        self.last = Some(elapsed);
985        elapsed - self.since.unwrap() >= 30.
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992    fn runtime() -> Runtime {
993        let directory = std::env::var_os("REGAIN_TEST_WORKERS")
994            .map(std::path::PathBuf::from)
995            .unwrap_or_else(|| {
996                std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/debug")
997            });
998        Runtime {
999            directory,
1000            sdk: "unused".into(),
1001            simulate: true,
1002            sdk_simulation: Some(json!({"instant":true})),
1003        }
1004    }
1005    fn selection(direct: bool) -> Selection {
1006        Selection {
1007            name: if direct {
1008                "ZWO ASI676MC"
1009            } else {
1010                "ZWO Simulated"
1011            }
1012            .into(),
1013            serial: None,
1014            direct,
1015            sdk_fallback: false,
1016            recovery: RecoveryOptions {
1017                reconnect_delay_seconds: 0.01,
1018                cooling_sample_seconds: 0.01,
1019                cooling_stable_samples: 1,
1020                ..RecoveryOptions::default()
1021            },
1022        }
1023    }
1024    fn exposure() -> Exposure {
1025        Exposure {
1026            width: 64,
1027            height: 64,
1028            bin: 1,
1029            x: 0,
1030            y: 0,
1031            microseconds: 10000,
1032            dark: true,
1033        }
1034    }
1035    fn log() -> Diagnostic {
1036        Arc::new(|_, _, _| {})
1037    }
1038    #[tokio::test]
1039    async fn managed_white_balance_survives_worker_recovery_and_retains_locked_gains() {
1040        use crate::white_balance::{Gains, Mode, Output, Settings};
1041        for direct in [false, true] {
1042            let token = CancellationToken::new();
1043            let mut s = Session::new(selection(direct), runtime(), log()).unwrap();
1044            s.connect(&token).await.unwrap();
1045            assert_eq!(s.snapshot().white_balance_capabilities["supported"], true);
1046            let settings = Settings {
1047                mode: Mode::Manual,
1048                gains: Gains { red: 2., blue: 0.5 },
1049                output: Output::Corrected,
1050            };
1051            s.set_white_balance(settings, &token).await.unwrap();
1052            s.set_white_balance(
1053                Settings {
1054                    mode: Mode::Locked,
1055                    ..settings
1056                },
1057                &token,
1058            )
1059            .await
1060            .unwrap();
1061            assert!(Session::queue_control(&s.status, 3, 50).is_err());
1062            let e = Exposure {
1063                dark: false,
1064                ..exposure()
1065            };
1066            let first = s.capture(e.clone(), &token).await.unwrap();
1067            assert_eq!(first.metadata["whiteBalance"]["applied"], true);
1068            s.invalidate().await;
1069            let recovered = s.capture(e, &token).await.unwrap();
1070            assert_eq!(first.pixels, recovered.pixels);
1071            assert_eq!(
1072                s.snapshot().white_balance.unwrap(),
1073                Settings {
1074                    mode: Mode::Locked,
1075                    ..settings
1076                }
1077            );
1078            s.set_white_balance(
1079                Settings {
1080                    mode: Mode::Once,
1081                    ..Settings::default()
1082                },
1083                &token,
1084            )
1085            .await
1086            .unwrap();
1087            s.capture(
1088                Exposure {
1089                    dark: false,
1090                    ..exposure()
1091                },
1092                &token,
1093            )
1094            .await
1095            .unwrap();
1096            let effective = s.snapshot().white_balance.unwrap();
1097            assert_eq!(effective.mode, Mode::Locked);
1098            s.invalidate().await;
1099            s.capture(exposure(), &token).await.unwrap();
1100            assert_eq!(s.snapshot().white_balance.unwrap(), effective);
1101            s.invalidate().await;
1102        }
1103    }
1104    #[test]
1105    fn cooling_requires_output_or_sustained_setpoint_and_resets_on_warming_or_gaps() {
1106        let mut hold = CoolingHold::default();
1107        for i in 0..=15 {
1108            assert_eq!(
1109                hold.observe(Some(-10.), Some(20), -10., 2., 2., i as f64 * 2.),
1110                i == 15
1111            );
1112        }
1113        assert!(!hold.observe(Some(-9.7), Some(20), -10., 2., 2., 32.));
1114        assert!(!hold.observe(Some(-10.), Some(20), -10., 2., 2., 100.));
1115        assert!(!hold.observe(Some(-10.), Some(0), -10., 2., 2., 102.));
1116    }
1117    #[tokio::test]
1118    async fn environment_refreshes_before_sdk_and_direct_exposures_finish() {
1119        for direct in [false, true] {
1120            let token = CancellationToken::new();
1121            let mut sel = selection(direct);
1122            if direct {
1123                sel.name = "ZWO ASI6200MM Pro".into();
1124            }
1125            let mut rt = runtime();
1126            rt.sdk_simulation = Some(json!({"instant":false}));
1127            let mut s = Session::new(sel, rt, log()).unwrap();
1128            s.connect(&token).await.unwrap();
1129            let shared = s.status.clone();
1130            let capture = async {
1131                s.capture(
1132                    Exposure {
1133                        microseconds: 6_000_000,
1134                        ..exposure()
1135                    },
1136                    &token,
1137                )
1138                .await
1139            };
1140            let observe = async {
1141                let deadline = Instant::now() + Duration::from_secs(5);
1142                while shared.lock().unwrap().phase != "Exposing" {
1143                    assert!(Instant::now() < deadline);
1144                    tokio::time::sleep(Duration::from_millis(10)).await;
1145                }
1146                // Make stale frontend values distinguishable from worker telemetry.
1147                {
1148                    let mut state = shared.lock().unwrap();
1149                    state.values.insert(8, 999);
1150                    state.values.insert(15, 99);
1151                }
1152                loop {
1153                    let state = shared.lock().unwrap().clone();
1154                    // The two worker reads update status separately. Wait for
1155                    // both before checking their values, including on slow CI.
1156                    if state.values[&8] != 999 && state.values[&15] != 99 {
1157                        assert_eq!(state.phase, "Exposing");
1158                        assert_eq!(state.values[&8], if direct { 250 } else { -100 });
1159                        assert_eq!(state.values[&15], if direct { 0 } else { 30 });
1160                        break;
1161                    }
1162                    assert!(
1163                        Instant::now() < deadline,
1164                        "Telemetry stayed stale during capture"
1165                    );
1166                    tokio::time::sleep(Duration::from_millis(20)).await;
1167                }
1168            };
1169            let (frame, ()) = tokio::join!(capture, observe);
1170            assert_eq!(frame.unwrap().pixels.len(), 8192);
1171            s.close().await;
1172        }
1173    }
1174    #[tokio::test]
1175    async fn usb_escalation_is_opt_in_bounded_and_obeys_exposure_limits() {
1176        for (threshold, retries, seconds, resets) in [
1177            (0, 3, 0.01, 0),
1178            (2, 3, 0.01, 1),
1179            (1, 0, 0.01, 0),
1180            (1, 3, 31., 0),
1181            (4, 3, 0.01, 0),
1182        ] {
1183            let events = Arc::new(Mutex::new(Vec::<String>::new()));
1184            let sink = events.clone();
1185            let mut sel = selection(false);
1186            sel.recovery.usb_reset_after_failures = threshold;
1187            sel.recovery.max_retries = retries;
1188            sel.recovery.ready_frame_download_retries = 0;
1189            let mut rt = runtime();
1190            rt.sdk_simulation = Some(json!({"instant":true,"fault":"download"}));
1191            let mut session = Session::new(
1192                sel,
1193                rt,
1194                Arc::new(move |_, event, _| sink.lock().unwrap().push(event.into())),
1195            )
1196            .unwrap();
1197            let token = CancellationToken::new();
1198            session.connect(&token).await.unwrap();
1199            assert!(
1200                session
1201                    .capture(
1202                        Exposure {
1203                            microseconds: (seconds * 1e6) as u64,
1204                            ..exposure()
1205                        },
1206                        &token
1207                    )
1208                    .await
1209                    .is_err()
1210            );
1211            assert_eq!(
1212                events
1213                    .lock()
1214                    .unwrap()
1215                    .iter()
1216                    .filter(|e| e.as_str() == "usb.reset")
1217                    .count(),
1218                resets
1219            );
1220            session.close().await;
1221        }
1222    }
1223    #[tokio::test]
1224    async fn usb_recovery_restores_controls_and_cancellation_never_resets() {
1225        let token = CancellationToken::new();
1226        let mut sel = selection(false);
1227        sel.recovery.usb_reset_after_failures = 1;
1228        sel.recovery.ready_frame_download_retries = 0;
1229        let mut session = Session::new(sel, runtime(), log()).unwrap();
1230        session.connect(&token).await.unwrap();
1231        Session::queue_control(&session.status, 0, 230).unwrap();
1232        session
1233            .call("fault", json!({"kind":"download"}), None, &token)
1234            .await
1235            .unwrap();
1236        let frame = session.capture(exposure(), &token).await.unwrap();
1237        assert_eq!(frame.metadata["usbResets"], 1);
1238        assert_eq!(frame.metadata["recoveries"], 1);
1239        assert_eq!(frame.metadata["controls"]["0"], 230);
1240        let cancelled = CancellationToken::new();
1241        cancelled.cancel();
1242        assert!(
1243            session
1244                .capture(exposure(), &cancelled)
1245                .await
1246                .err()
1247                .unwrap()
1248                .is::<Failure>()
1249        );
1250        session.close().await;
1251    }
1252    #[tokio::test]
1253    async fn sdk_ready_frame_read_retry_does_not_replace_exposure() {
1254        let token = CancellationToken::new();
1255        let mut s = Session::new(selection(false), runtime(), log()).unwrap();
1256        s.connect(&token).await.unwrap();
1257        s.call("fault", json!({"kind":"download"}), None, &token)
1258            .await
1259            .unwrap();
1260        let frame = s.capture(exposure(), &token).await.unwrap();
1261        assert_eq!(frame.metadata["recoveries"], 0);
1262        assert_eq!(frame.metadata["downloadRetries"], 1);
1263        assert_eq!(frame.pixels.len(), 8192);
1264        s.close().await;
1265    }
1266    #[tokio::test]
1267    async fn sdk_replacement_restores_controls_and_respects_long_exposure_limit() {
1268        for (seconds, allowed) in [(30., true), (30.000001, false), (1200., false)] {
1269            let token = CancellationToken::new();
1270            let mut sel = selection(false);
1271            sel.recovery.ready_frame_download_retries = 0;
1272            let mut s = Session::new(sel, runtime(), log()).unwrap();
1273            s.connect(&token).await.unwrap();
1274            Session::queue_control(&s.status, 0, 250).unwrap();
1275            Session::queue_control(&s.status, 16, -10).unwrap();
1276            s.call("fault", json!({"kind":"download"}), None, &token)
1277                .await
1278                .unwrap();
1279            let result = s
1280                .capture(
1281                    Exposure {
1282                        microseconds: (seconds * 1e6) as u64,
1283                        ..exposure()
1284                    },
1285                    &token,
1286                )
1287                .await;
1288            assert_eq!(result.is_ok(), allowed);
1289            if let Ok(frame) = result {
1290                assert_eq!(frame.metadata["recoveries"], 1);
1291                assert_eq!(frame.metadata["controls"]["0"], 250);
1292            }
1293            s.close().await;
1294        }
1295    }
1296    #[tokio::test]
1297    async fn direct_retained_reads_and_abort_share_the_recovery_session() {
1298        let token = CancellationToken::new();
1299        let mut s = Session::new(selection(true), runtime(), log()).unwrap();
1300        s.connect(&token).await.unwrap();
1301        s.call("simulate-read-failures", json!({"count":2}), None, &token)
1302            .await
1303            .unwrap();
1304        let frame = s.capture(exposure(), &token).await.unwrap();
1305        assert_eq!(frame.metadata["readRecoveries"], 2);
1306        assert_eq!(frame.metadata["recoveries"], 0);
1307        let abort = CancellationToken::new();
1308        let cancel = abort.clone();
1309        tokio::spawn(async move {
1310            tokio::time::sleep(Duration::from_millis(20)).await;
1311            cancel.cancel();
1312        });
1313        assert!(
1314            s.capture(
1315                Exposure {
1316                    microseconds: 2000000,
1317                    ..exposure()
1318                },
1319                &abort
1320            )
1321            .await
1322            .is_err()
1323        );
1324        assert_eq!(
1325            s.capture(exposure(), &token).await.unwrap().pixels.len(),
1326            8192
1327        );
1328        s.close().await;
1329    }
1330    #[tokio::test]
1331    async fn direct_fallback_revalidates_same_identity_and_uses_one_budget() {
1332        let token = CancellationToken::new();
1333        let mut sel = selection(true);
1334        sel.sdk_fallback = true;
1335        let mut runtime = runtime();
1336        runtime.sdk_simulation = Some(
1337            json!({"name":"ZWO ASI676MC","width":3552,"height":3552,"bins":[1],"serial":"direct-simulator","cooled":false,"instant":true}),
1338        );
1339        let mut s = Session::new(sel, runtime, log()).unwrap();
1340        s.connect(&token).await.unwrap();
1341        s.call("simulate-read-failures", json!({"count":3}), None, &token)
1342            .await
1343            .unwrap();
1344        let frame = s.capture(exposure(), &token).await.unwrap();
1345        assert_eq!(frame.metadata["backend"], "sdk");
1346        assert_eq!(frame.metadata["sdkFallback"], true);
1347        assert_eq!(frame.metadata["recoveries"], 1);
1348        s.close().await;
1349    }
1350    #[test]
1351    fn invalid_limits_are_rejected() {
1352        assert!(
1353            RecoveryOptions {
1354                maximum_retry_exposure_seconds: f64::NAN,
1355                ..RecoveryOptions::default()
1356            }
1357            .validate()
1358            .is_err()
1359        );
1360        assert!(
1361            RecoveryOptions {
1362                direct_read_retries: 6,
1363                ..RecoveryOptions::default()
1364            }
1365            .validate()
1366            .is_err()
1367        );
1368    }
1369}