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