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