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