Skip to main content

regain_core/
session.rs

1use crate::timing::{
2    ACKNOWLEDGED_CONTROLS, CLOSE_SECONDS, MAX_READ_RETRY_OVERHEAD_SECONDS, PERSISTENT_CONTROLS,
3    USB_BIND_SECONDS, USB_REBIND_PAUSE_SECONDS, USB_RESET_SECONDS,
4};
5use crate::*;
6use anyhow::{Context, Result, ensure};
7use chrono::Utc;
8use serde_json::{Value, json};
9use std::{
10    collections::BTreeMap,
11    sync::{Arc, Mutex},
12    time::Duration,
13};
14use tokio::time::Instant;
15
16#[cfg(any(windows, test))]
17fn validate_usb_helper_status(status: &Value, now: u64) -> Result<()> {
18    let authorized_lifetime = match (
19        status.get("sessionScoped"),
20        status.get("expiresAtUnixSeconds"),
21    ) {
22        (Some(Value::Bool(true)), Some(Value::Null)) => true,
23        (None | Some(Value::Bool(false)), Some(expiry)) => expiry
24            .as_u64()
25            .is_some_and(|expiry| expiry > now && expiry <= 253402300799),
26        _ => false,
27    };
28    ensure!(
29        status["protocolVersion"] == 1
30            && status["running"] == true
31            && status["elevated"] == true
32            && status["approved"] == true
33            && authorized_lifetime,
34        "USB recovery helper is not ready for this camera. Disconnect and prepare the helper in camera setup before imaging; automatic recovery never requests elevation."
35    );
36    Ok(())
37}
38
39#[derive(Clone, Copy, PartialEq, Eq)]
40enum OpenPurpose {
41    Acquisition,
42    ThermalShutdown,
43}
44/// One owner per camera. All public entry points are serialized by the frontend.
45/// Status and queued controls remain readable while capture owns the worker.
46pub struct Session {
47    pub status: SharedStatus,
48    pub selection: Selection,
49    runtime: Runtime,
50    log: Diagnostic,
51    worker: Option<Worker>,
52    direct: bool,
53    ever_opened: bool,
54    applied: BTreeMap<i32, i64>,
55    recovery_temperature: Option<f64>,
56    recovery_power: Option<i64>,
57    recovery_target: Option<i64>,
58    settle_required: bool,
59    cooling_seeded: bool,
60    usb_target: Option<String>,
61    cooling: cooling::Mailbox,
62    runtime_options: camera_options::CameraRuntimeHandle,
63    applied_runtime_options: Option<camera_options::CameraRuntimeOptions>,
64    reconnect_not_before: Option<Instant>,
65    connection_failures: u32,
66    outage_usb_reset: bool,
67    usb_peripherals: usb_peripherals::PeripheralRecoveryHandle,
68}
69impl Session {
70    pub fn new(selection: Selection, runtime: Runtime, log: Diagnostic) -> Result<Self> {
71        selection.recovery.validate()?;
72        let status = Arc::new(Mutex::new(Status::default()));
73        let observed = status.clone();
74        let log: Diagnostic = Arc::new(move |level, event, message| {
75            // The direct worker reports transfer retries while its status call
76            // is still pending. Surface those records without parsing prose or
77            // changing any recovery decisions. Final frame metadata reconciles
78            // successful reads if stderr delivery lagged behind the reply.
79            if matches!(event, "transfer.retry" | "transfer.exhausted")
80                && crate::worker::diagnostic_record(message)
81                    .is_none_or(|record| record.get("flightRecorderBatch").is_none())
82            {
83                let mut state = observed.lock().unwrap();
84                if matches!(
85                    state.phase.as_str(),
86                    "Starting exposure" | "Exposing" | "Downloading"
87                ) {
88                    if event == "transfer.retry" {
89                        state.retry.usb_reads = state.retry.usb_reads.saturating_add(1);
90                    }
91                    state.retry.last_failure = Some(crate::worker::diagnostic_message(message));
92                }
93            }
94            log(level, event, message);
95        });
96        Ok(Self {
97            usb_peripherals: usb_peripherals::PeripheralRecoveryHandle::new(status.clone()),
98            runtime_options: camera_options::CameraRuntimeHandle::new(&selection.recovery),
99            applied_runtime_options: None,
100            status,
101            direct: selection.direct,
102            selection,
103            runtime,
104            log,
105            worker: None,
106            ever_opened: false,
107            applied: BTreeMap::new(),
108            recovery_temperature: None,
109            recovery_power: None,
110            recovery_target: None,
111            settle_required: false,
112            cooling_seeded: false,
113            usb_target: None,
114            cooling: cooling::Mailbox::default(),
115            reconnect_not_before: None,
116            connection_failures: 0,
117            outage_usb_reset: false,
118        })
119    }
120    fn emit(&self, level: &str, event: &str, message: impl AsRef<str>) {
121        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
122            (self.log)(level, event, message.as_ref())
123        }));
124    }
125    fn phase(&self, phase: impl Into<String>) {
126        let phase = phase.into();
127        self.status.lock().unwrap().phase = phase.clone();
128        self.emit(
129            if matches!(
130                phase.as_str(),
131                "Idle" | "Starting exposure" | "Exposing" | "Downloading"
132            ) {
133                "debug"
134            } else {
135                "info"
136            },
137            "session.phase",
138            phase,
139        );
140    }
141    pub fn snapshot(&self) -> Status {
142        self.status.lock().unwrap().clone()
143    }
144    pub fn cooling(&self) -> cooling::CoolingHandle {
145        cooling::CoolingHandle {
146            mailbox: self.cooling.clone(),
147            status: self.status.clone(),
148        }
149    }
150    pub fn runtime_options(&self) -> camera_options::CameraRuntimeHandle {
151        self.runtime_options.clone()
152    }
153    /// Opt in only when the frontend can reconnect and verify camera-hub
154    /// peripherals. Configure before connecting; ordinary frontends are inert.
155    pub fn wait_for_usb_peripherals(&self, enabled: bool) -> Result<()> {
156        ensure!(
157            !self.ever_opened,
158            invalid("Configure USB peripheral recovery before connecting")
159        );
160        self.usb_peripherals.enable(enabled)
161    }
162    pub fn usb_peripheral_recovery(&self) -> usb_peripherals::PeripheralRecoveryHandle {
163        self.usb_peripherals.clone()
164    }
165    fn emit_usb_episode(&self, level: &str, event: &str, message: &str, id: Option<u64>) {
166        self.emit(
167            level,
168            event,
169            format!(
170                "REGAIN_DIAGNOSTIC {}",
171                json!({
172                    "version":1,"level":level,"event":event,"message":message,
173                "pid":std::process::id(),"timestampUnixUs":Utc::now().timestamp_micros(),
174                "details":{"usbPeripheralRecoveryId":id}
175                })
176            ),
177        );
178    }
179    /// Drive one reserved command while idle. Capture drives the same mailbox
180    /// at safe worker checkpoints. Frontends retain/serialize this operation.
181    pub async fn service_cooling(&mut self, token: &CancellationToken) -> Result<()> {
182        self.service_cooling_with(&mut self.settings(), token).await
183    }
184    async fn service_cooling_with(
185        &mut self,
186        settings: &mut BTreeMap<i32, i64>,
187        token: &CancellationToken,
188    ) -> Result<()> {
189        let Some(request) = self.cooling.claim() else {
190            return Ok(());
191        };
192        let (kind, value, deadline) = {
193            let request = request.lock().unwrap();
194            (request.kind, request.value, request.deadline)
195        };
196        let deadline = deadline.min(
197            Instant::now()
198                + Duration::from_secs_f64(self.selection.recovery.command_timeout_seconds),
199        );
200        let mailbox = self.cooling.clone();
201        if let Err(error) = cooling::validate(&self.status, kind, value) {
202            let _ = mailbox.finish(&request, Err(error), || {});
203            return Ok(());
204        }
205        if token.is_cancelled() {
206            let _ = mailbox.finish(&request, Err(cooling::CoolingError::Cancelled), || {});
207            return Err(Failure::Cancelled.into());
208        }
209        let remaining = deadline
210            .saturating_duration_since(Instant::now())
211            .as_secs_f64();
212        if remaining == 0. {
213            let _ = mailbox.finish(&request, Err(cooling::CoolingError::Expired), || {});
214            return Ok(());
215        }
216        if !mailbox.dispatch(&request) {
217            return Ok(());
218        }
219        let result = self
220            .write_control(kind, value, deadline, token, false)
221            .await;
222        let observation = result.as_ref().ok().copied();
223        let result = mailbox.finish(&request, result.map(|observed| observed.value), || {
224            self.applied.insert(kind, value);
225            settings.insert(kind, value);
226            let mut state = self.status.lock().unwrap();
227            state.values.insert(kind, value);
228            if let Some(observation) = observation {
229                state.observations.insert(kind, observation);
230            }
231        });
232        match result {
233            Err(error @ cooling::CoolingError::Uncertain { .. }) => {
234                self.control_result(Err(error)).await.map(|_| ())
235            }
236            Err(cooling::CoolingError::Cancelled) => Err(Failure::Cancelled.into()),
237            _ => Ok(()), // Unsent expiry/failure belongs to the command receipt.
238        }
239    }
240    /// Acknowledge gain/offset while idle, using the same serialized worker as
241    /// capture. The caller must retain this future through dispatch/cleanup.
242    /// Unlike queue_control, success means write plus readback, not desired intent.
243    /// The absolute deadline includes time spent waiting for owner admission.
244    pub async fn set_imaging_control(
245        &mut self,
246        kind: i32,
247        value: i64,
248        deadline: Instant,
249        token: &CancellationToken,
250    ) -> Result<i64> {
251        ensure!(
252            matches!(kind, 0 | 5),
253            invalid("Only gain and offset are supported")
254        );
255        let state = self.snapshot();
256        ensure!(
257            state.connected && state.control_connection_available && self.worker.is_some(),
258            invalid("Camera controls are unavailable")
259        );
260        ensure!(
261            !self.cooling().pending(),
262            invalid("A cooler command is pending")
263        );
264        let cap = state
265            .controls
266            .get(&kind)
267            .ok_or_else(|| invalid("Control is unavailable"))?;
268        ensure!(
269            cap.kind == kind
270                && cap.writable
271                && cap.min <= cap.max
272                && (cap.min..=cap.max).contains(&value),
273            invalid("Control value is outside writable capabilities")
274        );
275        if token.is_cancelled() {
276            return Err(Failure::Cancelled.into());
277        }
278        if Instant::now() >= deadline {
279            return Err(cooling::CoolingError::Expired.into());
280        }
281        let deadline = deadline.min(
282            Instant::now()
283                + Duration::from_secs_f64(self.selection.recovery.command_timeout_seconds),
284        );
285        let result = self.write_control(kind, value, deadline, token, true).await;
286        let observation = self.control_result(result).await?;
287        self.applied.insert(kind, observation.value);
288        let mut state = self.status.lock().unwrap();
289        state.values.insert(kind, observation.value);
290        state.observations.insert(kind, observation);
291        Ok(observation.value)
292    }
293    async fn write_control(
294        &mut self,
295        kind: i32,
296        value: i64,
297        deadline: Instant,
298        token: &CancellationToken,
299        allow_sdk_offset_clamp: bool,
300    ) -> std::result::Result<ControlObservation, cooling::CoolingError> {
301        let mut set_acknowledged = false;
302        let result = async {
303            self.worker
304                .as_mut()
305                .context("Camera worker is disconnected")?
306                .control_call(
307                    "set",
308                    json!({"control":kind,"value":value}),
309                    deadline,
310                    token,
311                )
312                .await?;
313            set_acknowledged = true;
314            self.emit(
315                "debug",
316                "control.write_acknowledged",
317                format!("Control {kind}; readback pending"),
318            );
319            let remaining = deadline
320                .saturating_duration_since(Instant::now())
321                .as_secs_f64();
322            ensure!(remaining > 0., "Control deadline expired before readback");
323            let request_started = Instant::now();
324            // Legacy SDK controls and direct LED/heater/fan settings retain
325            // their ordinary scalar readback. All writes still share one
326            // absolute write/readback deadline and uncertainty boundary.
327            let timed_observation = ACKNOWLEDGED_CONTROLS.contains(&kind);
328            let reply = self
329                .worker
330                .as_mut()
331                .context("Camera worker is disconnected")?
332                .control_call(
333                    if timed_observation {
334                        "get-observation"
335                    } else {
336                        "get"
337                    },
338                    json!({"control":kind}),
339                    deadline,
340                    token,
341                )
342                .await?
343                .0;
344            let observation = if timed_observation {
345                serde_json::from_value::<ControlObservationReply>(reply)
346                    .map_err(|_| invalid("Invalid control readback"))?
347                    .normalize(request_started)?
348            } else {
349                ControlObservation {
350                    value: reply
351                        .as_i64()
352                        .ok_or_else(|| invalid("Invalid control readback"))?,
353                    observed_at: request_started,
354                }
355            };
356            let actual = observation.value;
357            if actual != value {
358                let cap = self.snapshot().controls.get(&kind).cloned();
359                ensure!(
360                    allow_sdk_offset_clamp
361                        && !self.direct
362                        && kind == 5
363                        && cap.is_some_and(|cap| (cap.min..=cap.max).contains(&actual)),
364                    "Control readback differs from request"
365                );
366                self.emit(
367                    "info",
368                    "control.clamped",
369                    format!("SDK applied offset {actual} instead of {value}"),
370                );
371            }
372            ensure!(
373                Instant::now() < deadline,
374                "Control readback exceeded its deadline"
375            );
376            Ok::<_, anyhow::Error>(observation)
377        }
378        .await;
379        result.map_err(|error| {
380            if !set_acknowledged
381                && matches!(
382                    error.downcast_ref::<cooling::CoolingError>(),
383                    Some(cooling::CoolingError::Expired)
384                )
385            {
386                cooling::CoolingError::Expired
387            } else if !set_acknowledged
388                && matches!(error.downcast_ref::<Failure>(), Some(Failure::Cancelled))
389            {
390                cooling::CoolingError::Cancelled
391            } else {
392                let code = match error.downcast_ref::<Failure>() {
393                    Some(Failure::Worker { code, .. } | Failure::UncertainControl { code, .. }) => {
394                        *code
395                    }
396                    _ => None,
397                };
398                cooling::CoolingError::Uncertain {
399                    message: format!("{error:#}"),
400                    code,
401                }
402            }
403        })
404    }
405    async fn control_result(
406        &mut self,
407        result: std::result::Result<ControlObservation, cooling::CoolingError>,
408    ) -> Result<ControlObservation> {
409        match result {
410            Err(cooling::CoolingError::Uncertain { message, code }) => {
411                self.invalidate().await;
412                Err(Failure::UncertainControl { message, code }.into())
413            }
414            Err(cooling::CoolingError::Cancelled) => {
415                // Worker cancellation can retire an otherwise untouched process.
416                // Withdraw its core availability before another owner operation.
417                self.invalidate().await;
418                Err(Failure::Cancelled.into())
419            }
420            Err(error) => Err(error.into()),
421            Ok(actual) => Ok(actual),
422        }
423    }
424    /// Opt into Regain-owned WB. The caller serializes this with capture, like
425    /// all Session operations. Settings and effective AWB gains survive recovery.
426    pub async fn set_white_balance(
427        &mut self,
428        settings: crate::white_balance::Settings,
429        token: &CancellationToken,
430    ) -> Result<()> {
431        settings.gains.validate()?;
432        let result = self
433            .call(
434                "white-balance",
435                serde_json::to_value(settings)?,
436                None,
437                token,
438            )
439            .await?
440            .0;
441        self.status.lock().unwrap().white_balance =
442            Some(serde_json::from_value(result["settings"].clone())?);
443        // Legacy WB/flip controls must not be replayed over managed WB.
444        for kind in [3, 4, 9] {
445            self.applied.remove(&kind);
446        }
447        Ok(())
448    }
449    pub async fn simulate_read_failures(
450        &mut self,
451        count: u32,
452        token: &CancellationToken,
453    ) -> Result<()> {
454        ensure!(
455            self.runtime.simulate && self.direct,
456            "Read fault injection requires a simulated direct camera"
457        );
458        self.call(
459            "simulate-read-failures",
460            json!({"count":count}),
461            None,
462            token,
463        )
464        .await?;
465        Ok(())
466    }
467    pub async fn dump_diagnostics(&mut self, token: &CancellationToken) -> Result<()> {
468        self.call("diagnostics-dump", Value::Null, None, token)
469            .await?;
470        Ok(())
471    }
472    pub fn seed_recovery(&mut self, temperature: Option<f64>, power: Option<i64>) {
473        self.recovery_temperature = temperature.filter(|t| t.is_finite());
474        self.recovery_power = power;
475        self.settle_required = true;
476    }
477    pub fn seed_recovery_target(&mut self, target: Option<i64>) {
478        self.recovery_target = target.filter(|v| (-40..=30).contains(v));
479    }
480    pub fn queue_control(status: &SharedStatus, kind: i32, value: i64) -> Result<()> {
481        let mut state = status.lock().unwrap();
482        ensure!(
483            state.white_balance.is_none() || !matches!(kind, 3 | 4 | 9),
484            Failure::Invalid("WB and flip controls are owned by managed white balance".into())
485        );
486        let cap = state
487            .controls
488            .get(&kind)
489            .ok_or_else(|| invalid(format!("Control {kind} unavailable")))?;
490        ensure!(
491            cap.writable && value >= cap.min && value <= cap.max,
492            Failure::Invalid(format!(
493                "Control {kind} must be writable and between {} and {}",
494                cap.min, cap.max
495            ))
496        );
497        ensure!(
498            PERSISTENT_CONTROLS.contains(&kind),
499            Failure::Invalid("Control is not a persistent imaging setting".into())
500        );
501        state.values.insert(kind, value);
502        Ok(())
503    }
504    async fn call(
505        &mut self,
506        method: &str,
507        params: Value,
508        seconds: Option<f64>,
509        token: &CancellationToken,
510    ) -> Result<(Value, Vec<u8>)> {
511        if method != "open" {
512            let options = self.runtime_options.get();
513            if self.applied_runtime_options.as_ref() != Some(&options) {
514                self.worker
515                    .as_mut()
516                    .context("Camera worker is disconnected")?
517                    .call(
518                        "runtime-options",
519                        serde_json::to_value(&options)?,
520                        self.selection.recovery.command_timeout_seconds,
521                        token,
522                    )
523                    .await?;
524                options.apply(&mut self.selection.recovery);
525                self.applied_runtime_options = Some(options);
526            }
527        }
528        self.worker
529            .as_mut()
530            .context("Camera worker is disconnected")?
531            .call(
532                method,
533                params,
534                seconds.unwrap_or(self.selection.recovery.command_timeout_seconds),
535                token,
536            )
537            .await
538    }
539    async fn delay(&self, seconds: f64, token: &CancellationToken) -> Result<()> {
540        tokio::select! {biased;_=token.cancelled()=>Err(Failure::Cancelled.into()),_=tokio::time::sleep(Duration::from_secs_f64(seconds))=>Ok(())}
541    }
542    async fn invalidate(&mut self) {
543        self.cooling.retire();
544        if self.ever_opened && !self.settle_required {
545            let state = self.snapshot();
546            self.recovery_temperature = state.values.get(&8).map(|v| *v as f64 / 10.);
547            self.recovery_power = state.values.get(&15).copied();
548            self.recovery_target = self
549                .applied
550                .get(&16)
551                .copied()
552                .or_else(|| state.values.get(&16).copied());
553            self.settle_required = true;
554        }
555        self.status.lock().unwrap().control_connection_available = false;
556        if let Some(mut worker) = self.worker.take() {
557            worker.kill().await;
558        }
559        self.status.lock().unwrap().process_id = None;
560    }
561    /// One outage clock survives telemetry calls and failed capture requests.
562    /// Invalidating a worker for identity binding or shutdown is not a fault.
563    fn connection_failed(&mut self) {
564        self.connection_failures = self.connection_failures.saturating_add(1);
565        self.reconnect_not_before = Some(
566            Instant::now()
567                + Duration::from_secs_f64(self.selection.recovery.reconnect_delay_seconds),
568        );
569    }
570    async fn reconnect_delay(&self, token: &CancellationToken) -> Result<()> {
571        self.phase("Reconnect delay");
572        let seconds = self
573            .reconnect_not_before
574            .map(|until| {
575                until
576                    .saturating_duration_since(Instant::now())
577                    .as_secs_f64()
578            })
579            .unwrap_or(self.selection.recovery.reconnect_delay_seconds);
580        self.delay(seconds, token).await
581    }
582    async fn fallback(&mut self, reason: &str, token: &CancellationToken) -> Result<()> {
583        self.status.lock().unwrap().retry.last_failure = Some(reason.into());
584        self.emit(
585            "warning",
586            "backend.fallback",
587            format!("Switching to SDK fallback: {reason}"),
588        );
589        self.invalidate().await;
590        self.direct = false;
591        self.phase("SDK fallback reconnect delay");
592        self.delay(self.selection.recovery.reconnect_delay_seconds, token)
593            .await
594    }
595    pub async fn connect(&mut self, token: &CancellationToken) -> Result<()> {
596        self.usb_peripherals.reconnect_after_close()?;
597        let result = async {
598            match self.open(token).await {
599                Err(error) if self.direct && self.selection.sdk_fallback && retryable(&error) => {
600                    self.fallback(&error.to_string(), token).await?;
601                    self.open(token).await
602                }
603                other => other,
604            }?;
605            if self.selection.recovery.usb_reset_after_failures > 0 && self.usb_target.is_none() {
606                // First connection can learn the serial. Release its handle before
607                // binding the physical device, then reopen that exact serial.
608                self.invalidate().await;
609                self.bind_usb(token).await?;
610                self.open(token).await?;
611            }
612            self.ever_opened = true;
613            self.status.lock().unwrap().connected = true;
614            self.connection_failures = 0;
615            self.reconnect_not_before = None;
616            self.outage_usb_reset = false;
617            self.phase("Idle");
618            Ok(())
619        }
620        .await;
621        if let Err(error) = &result {
622            if !token.is_cancelled() {
623                self.status.lock().unwrap().retry.last_failure = Some(format!("{error:#}"));
624            }
625            self.emit("warning", "connection.failed", format!("{error:#}"));
626            self.invalidate().await;
627        }
628        result
629    }
630    async fn bind_usb(&mut self, token: &CancellationToken) -> Result<()> {
631        if token.is_cancelled() {
632            return Err(Failure::Cancelled.into());
633        }
634        if self.runtime.simulate {
635            self.usb_target = Some("simulation".into());
636            return Ok(());
637        }
638        let serial = self
639            .selection
640            .serial
641            .as_deref()
642            .context("USB recovery requires a camera serial")?;
643        let args = [
644            "zwo",
645            "camera-direct",
646            "--usb-target",
647            &self.selection.name,
648            serial,
649        ];
650        let deadline = Instant::now() + Duration::from_secs(USB_BIND_SECONDS);
651        let command = self.runtime.usb_command(&args, USB_BIND_SECONDS);
652        let encoded = tokio::select! { biased;
653            _=token.cancelled()=>return Err(Failure::Cancelled.into()),
654            r=tokio::time::timeout_at(deadline, command)=>r.context("USB camera binding deadline exceeded")??
655        };
656        let target = regain_transport::usb::Target::decode(&encoded)?;
657        ensure!(
658            target.serial.eq_ignore_ascii_case(serial),
659            "USB recovery serial mismatch"
660        );
661        #[cfg(windows)]
662        {
663            // Preflight uses the same total binding deadline and never starts
664            // or elevates a helper. Missing preparation must be visible before
665            // imaging, rather than creating an unattended UAC prompt on fault.
666            let args = ["usb", "helper", "status", &encoded];
667            let command = self.runtime.usb_command(&args, USB_BIND_SECONDS);
668            let reply = tokio::select! { biased;
669                _=token.cancelled()=>return Err(Failure::Cancelled.into()),
670                r=tokio::time::timeout_at(deadline, command)=>r.context("USB helper readiness deadline exceeded")??
671            };
672            let status: Value =
673                serde_json::from_str(&reply).context("Invalid USB helper status")?;
674            validate_usb_helper_status(&status, Utc::now().timestamp().max(0) as u64)?;
675            self.emit("info", "usb.helper.ready", "Pre-elevated USB recovery helper is ready for the selected camera; recovery will not request Windows elevation");
676        }
677        self.usb_target = Some(encoded);
678        self.emit(
679            "info",
680            "usb.bound",
681            "USB recovery bound to the selected camera's serial and physical location",
682        );
683        Ok(())
684    }
685    async fn reset_usb(&mut self, token: &CancellationToken) -> Result<()> {
686        if token.is_cancelled() {
687            return Err(Failure::Cancelled.into());
688        }
689        let target = self
690            .usb_target
691            .as_deref()
692            .context("No verified USB recovery target")?;
693        ensure!(
694            self.worker.is_none(),
695            "Close the camera worker before USB recovery"
696        );
697        let peripheral_recovery = self.usb_peripherals.begin()?;
698        let episode = peripheral_recovery.as_ref().map(|recovery| recovery.id);
699        self.phase("USB recovery");
700        self.emit_usb_episode(
701            "warning",
702            "usb.reset",
703            "Resetting the selected camera; the retained frame is abandoned. Camera-hub peripherals may also disconnect and need reconnecting in the imaging application",
704            episode,
705        );
706        if !self.runtime.simulate {
707            // Once dispatched, finish the bounded helper before honoring abort so
708            // a Linux port cycle can always re-enable the port.
709            let result = self
710                .runtime
711                .usb_command(
712                    &[
713                        "usb",
714                        if self.selection.recovery.usb_port_cycle {
715                            "cycle"
716                        } else {
717                            "reset"
718                        },
719                        target,
720                    ],
721                    USB_RESET_SECONDS,
722                )
723                .await?;
724            if !result.is_empty() {
725                self.emit("info", "usb.helper.result", result);
726            }
727        }
728        self.usb_target = None;
729        if token.is_cancelled() {
730            return Err(Failure::Cancelled.into());
731        }
732        self.phase("Waiting for USB camera");
733        let deadline = Instant::now() + Duration::from_secs(USB_BIND_SECONDS);
734        loop {
735            match self.bind_usb(token).await {
736                Ok(()) => break,
737                Err(e) if token.is_cancelled() || Instant::now() >= deadline => return Err(e),
738                Err(_) => self.delay(USB_REBIND_PAUSE_SECONDS, token).await?,
739            }
740        }
741        self.emit_usb_episode(
742            "info",
743            "usb.returned",
744            "The same camera serial is available after USB recovery",
745            episode,
746        );
747        if let Some(recovery) = peripheral_recovery {
748            self.phase("Waiting for NINA peripherals");
749            self.emit_usb_episode(
750                "info",
751                "usb.peripherals.wait",
752                "Waiting for NINA to verify peripherals before another exposure",
753                episode,
754            );
755            recovery.wait(token).await?;
756            self.emit_usb_episode(
757                "info",
758                "usb.peripherals.ready",
759                "NINA confirmed peripherals ready for another exposure",
760                episode,
761            );
762        } else {
763            self.emit("warning", "usb.peripherals", "USB recovery verified the camera only; check peripherals connected through its hub and reconnect them if needed");
764        }
765        Ok(())
766    }
767    async fn open(&mut self, token: &CancellationToken) -> Result<()> {
768        self.open_worker(token, OpenPurpose::Acquisition).await
769    }
770    async fn open_worker(&mut self, token: &CancellationToken, purpose: OpenPurpose) -> Result<()> {
771        self.runtime_options
772            .get()
773            .apply(&mut self.selection.recovery);
774        self.applied_runtime_options = None;
775        if self.ever_opened && self.selection.serial.is_none() {
776            return Err(invalid(
777                "Automatic recovery requires a camera serial number",
778            ));
779        }
780        self.phase(if purpose == OpenPurpose::ThermalShutdown {
781            "Closing camera"
782        } else {
783            "Opening"
784        });
785        self.worker = Some(
786            self.runtime
787                .spawn_configured(
788                    self.direct,
789                    self.log.clone(),
790                    Some(&self.selection.recovery),
791                )
792                .await?,
793        );
794        self.applied.clear();
795        // Values retain desired recovery settings; old evidence cannot describe
796        // a replacement worker or its newly negotiated capabilities.
797        self.status.lock().unwrap().observations.clear();
798        self.cooling_seeded = false;
799        let (result, _) = self
800            .call(
801                "open",
802                json!({"name":self.selection.name,"serial":self.selection.serial,"recovery":self.selection.recovery}),
803                None,
804                token,
805            )
806            .await?;
807        let serial = result["serial"].as_str().map(str::to_owned);
808        ensure!(
809            self.selection.serial.is_none() || self.selection.serial == serial,
810            Failure::Invalid("Camera identity changed".into())
811        );
812        let info = &result["info"];
813        ensure!(
814            info["name"] == self.selection.name
815                && info["formats"]
816                    .as_array()
817                    .is_some_and(|a| a.contains(&json!(2))),
818            Failure::Invalid("Camera identity or RAW16 support changed".into())
819        );
820        let previous = self.snapshot();
821        ensure!(
822            !self.ever_opened
823                || (previous.info["width"] == info["width"]
824                    && previous.info["height"] == info["height"]),
825            Failure::Invalid("Camera geometry changed".into())
826        );
827        let mut controls: Vec<Control> = serde_json::from_value(result["controls"].clone())
828            .map_err(|_| invalid("Invalid camera controls"))?;
829        if self.direct
830            && self.selection.sdk_fallback
831            && matches!(
832                self.selection.name.as_str(),
833                "ZWO ASI2600MM Duo" | "ZWO ASI220MM Mini"
834            )
835            && let Some(exp) = controls.iter_mut().find(|c| c.kind == 1)
836        {
837            exp.max = 2_000_000_000;
838        }
839        ensure!(
840            controls.iter().any(|c| c.kind == 1),
841            Failure::Invalid("Exposure control unavailable".into())
842        );
843        if info["cooled"] == true {
844            ensure!(
845                [8, 16, 17]
846                    .iter()
847                    .all(|k| controls.iter().any(|c| c.kind == *k)),
848                Failure::Invalid("Required cooling controls unavailable".into())
849            );
850        }
851        self.selection.serial = serial.clone();
852        {
853            let mut state = self.status.lock().unwrap();
854            state.info = info.clone();
855            state.serial = serial;
856            state.sdk_version = result["sdkVersion"].as_str().unwrap_or("unknown").into();
857            state.backend = if self.direct { "direct" } else { "sdk" }.into();
858            state.sdk_fallback = self.selection.direct && !self.direct;
859            state.controls = controls.into_iter().map(|c| (c.kind, c)).collect();
860            for c in state.controls.values().cloned().collect::<Vec<_>>() {
861                if (c.writable || c.kind == 6) && PERSISTENT_CONTROLS.contains(&c.kind) {
862                    state.values.entry(c.kind).or_insert(c.value);
863                } else {
864                    state.values.insert(c.kind, c.value);
865                }
866            }
867            state.control_connection_available = purpose == OpenPurpose::Acquisition;
868            state.process_id = self.worker.as_ref().and_then(Worker::pid);
869            state.white_balance_capabilities = result["whiteBalance"].clone();
870        }
871        if purpose == OpenPurpose::Acquisition
872            && let Some(settings) = previous.white_balance
873        {
874            // A new worker has no previous estimate for Locked to freeze.
875            if settings.mode == crate::white_balance::Mode::Locked {
876                self.set_white_balance(
877                    crate::white_balance::Settings {
878                        mode: crate::white_balance::Mode::Manual,
879                        ..settings
880                    },
881                    token,
882                )
883                .await?;
884            }
885            self.set_white_balance(settings, token).await?;
886        }
887        self.emit(
888            "info",
889            if purpose == OpenPurpose::ThermalShutdown {
890                "camera.cleanup_opened"
891            } else {
892                "connection.opened"
893            },
894            format!(
895                "Camera opened using {}{}; serial {}{}",
896                if self.direct { "direct" } else { "SDK" },
897                if self.selection.direct && !self.direct {
898                    " fallback"
899                } else {
900                    ""
901                },
902                self.selection.serial.as_deref().unwrap_or("unavailable"),
903                if purpose == OpenPurpose::ThermalShutdown {
904                    "; thermal shutdown only"
905                } else {
906                    ""
907                }
908            ),
909        );
910        if purpose == OpenPurpose::Acquisition {
911            self.cooling.activate();
912        }
913        Ok(())
914    }
915    fn settings(&self) -> BTreeMap<i32, i64> {
916        let state = self.snapshot();
917        state
918            .values
919            .into_iter()
920            .filter(|(k, _)| {
921                (state.white_balance.is_none() || !matches!(k, 3 | 4 | 9))
922                    && state.controls.get(k).is_some_and(|c| c.writable || *k == 6)
923                    && PERSISTENT_CONTROLS.contains(k)
924            })
925            .collect()
926    }
927    async fn apply(
928        &mut self,
929        values: &mut BTreeMap<i32, i64>,
930        token: &CancellationToken,
931    ) -> Result<()> {
932        let mut ordered: Vec<_> = values.iter().map(|(k, v)| (*k, *v)).collect();
933        ordered.sort_by_key(|(k, _)| if *k == 17 { 100 } else { *k });
934        for (kind, value) in ordered {
935            if self.snapshot().white_balance.is_some() && matches!(kind, 3 | 4 | 9) {
936                continue;
937            }
938            if self.applied.get(&kind) == Some(&value) {
939                continue;
940            }
941            let state = self.snapshot();
942            let cap = state
943                .controls
944                .get(&kind)
945                .ok_or_else(|| invalid(format!("Control {kind} disappeared after reconnect")))?;
946            if !cap.writable {
947                ensure!(
948                    cap.value == value,
949                    Failure::Invalid(format!("Read-only control {kind} changed"))
950                );
951                self.applied.insert(kind, value);
952                continue;
953            }
954            let deadline = Instant::now()
955                + Duration::from_secs_f64(self.selection.recovery.command_timeout_seconds);
956            let result = self.write_control(kind, value, deadline, token, true).await;
957            let observation = self.control_result(result).await?;
958            let actual = observation.value;
959            if actual != value {
960                ensure!(
961                    !self.direct && kind == 5 && actual >= cap.min && actual <= cap.max,
962                    "Control {kind} readback {actual} differs from requested {value}"
963                );
964                // The shared helper already emits the SDK offset clamp
965                // diagnostic; no other control has a clamping policy.
966                values.insert(kind, actual);
967                let mut shared = self.status.lock().unwrap();
968                if shared.values.get(&kind) == Some(&value) {
969                    shared.values.insert(kind, actual);
970                }
971            }
972            self.applied.insert(kind, actual);
973            self.status
974                .lock()
975                .unwrap()
976                .observations
977                .insert(kind, observation);
978        }
979        if self.direct
980            && self.settle_required
981            && !self.cooling_seeded
982            && values.get(&17).is_some_and(|v| *v != 0)
983            && let (Some(power), Some(temperature)) =
984                (self.recovery_power, self.recovery_temperature)
985        {
986            self.call(
987                "resume-cooling",
988                json!({"power":power,"temperature":temperature,"previousTarget":self.recovery_target.or_else(|| values.get(&16).copied()).unwrap_or(0)}),
989                None,
990                token,
991            )
992            .await?;
993            self.cooling_seeded = true;
994            self.emit(
995                "info",
996                "cooling.resumed",
997                format!(
998                    "Resumed prior cooler demand {power}% at restored target {} C",
999                    values.get(&16).unwrap_or(&0)
1000                ),
1001            );
1002        }
1003        Ok(())
1004    }
1005    async fn observe_control(
1006        &mut self,
1007        kind: i32,
1008        token: &CancellationToken,
1009    ) -> Result<ControlObservation> {
1010        let request_started = Instant::now();
1011        let (reply, pixels) = self
1012            .call("get-observation", json!({"control":kind}), None, token)
1013            .await?;
1014        ensure!(
1015            pixels.is_empty(),
1016            invalid("Unexpected observation image payload")
1017        );
1018        serde_json::from_value::<ControlObservationReply>(reply)
1019            .map_err(|_| invalid("Invalid control observation"))?
1020            .normalize(request_started)
1021    }
1022    async fn read_environment(
1023        &mut self,
1024        token: &CancellationToken,
1025    ) -> Result<(Option<f64>, Option<i64>)> {
1026        for kind in [8, 15] {
1027            if self.snapshot().controls.contains_key(&kind) {
1028                let observation = self.observe_control(kind, token).await?;
1029                let mut state = self.status.lock().unwrap();
1030                state.values.insert(kind, observation.value);
1031                state.observations.insert(kind, observation);
1032            }
1033        }
1034        let state = self.snapshot();
1035        Ok((
1036            state.values.get(&8).map(|v| *v as f64 / 10.),
1037            state.values.get(&15).copied(),
1038        ))
1039    }
1040    /// Read-only idle telemetry on the existing worker. Unlike refresh, this
1041    /// never opens a replacement worker or applies desired configuration.
1042    pub async fn refresh_environment(&mut self, token: &CancellationToken) -> Result<()> {
1043        let state = self.snapshot();
1044        ensure!(
1045            state.connected && state.control_connection_available && self.worker.is_some(),
1046            invalid("Camera controls are unavailable")
1047        );
1048        let result = self.read_environment(token).await.map(|_| ());
1049        if let Err(error) = &result {
1050            self.emit("warning", "environment.failed", format!("{error:#}"));
1051            if retryable(error) && !token.is_cancelled() {
1052                self.connection_failed();
1053            }
1054            self.invalidate().await;
1055        }
1056        result
1057    }
1058    pub async fn refresh(&mut self, token: &CancellationToken) -> Result<()> {
1059        // Idle telemetry must not compete with acquisition by repeatedly
1060        // opening new workers while a camera is absent. Keep cached evidence;
1061        // capture owns the bounded, cancellable pre-arm reconnect transaction.
1062        ensure!(
1063            self.worker.is_some(),
1064            invalid("Camera controls are unavailable; reconnection is deferred until capture")
1065        );
1066        let result = async {
1067            self.apply(&mut self.settings(), token).await?;
1068            self.read_environment(token).await?;
1069            Ok(())
1070        }
1071        .await;
1072        if let Err(error) = &result {
1073            if !token.is_cancelled() {
1074                self.status.lock().unwrap().retry.last_failure = Some(format!("{error:#}"));
1075            }
1076            self.emit("warning", "controls.failed", format!("{error:#}"));
1077            if retryable(error) && !token.is_cancelled() {
1078                self.connection_failed();
1079            }
1080            self.invalidate().await;
1081        }
1082        result
1083    }
1084    pub fn ready_timeout(&self, seconds: f64) -> f64 {
1085        let o = &self.selection.recovery;
1086        seconds
1087            + o.exposure_grace_seconds
1088            + if self.direct {
1089                (1 + if self.retained() || seconds <= o.maximum_retry_exposure_seconds {
1090                    o.direct_read_retries
1091                } else {
1092                    0
1093                }) as f64
1094                    * o.download_timeout_seconds
1095                    + o.direct_read_retries as f64
1096                        * self.snapshot().info["readRetryOverheadSeconds"]
1097                            .as_f64()
1098                            .unwrap_or(0.)
1099                            .clamp(0., MAX_READ_RETRY_OVERHEAD_SECONDS)
1100            } else {
1101                0.
1102            }
1103    }
1104    fn retained(&self) -> bool {
1105        self.direct && self.snapshot().info["retainedFrameReads"] == true
1106    }
1107    pub async fn capture(&mut self, e: Exposure, token: &CancellationToken) -> Result<Frame> {
1108        self.usb_peripherals.ensure_ready()?;
1109        if self.snapshot().white_balance.is_some() {
1110            let state = self.snapshot();
1111            crate::white_balance::WhiteBalance::validate_geometry(
1112                state.info["color"] == true,
1113                state.info["bayer"].as_u64(),
1114                e.bin,
1115            )
1116            .map_err(|error| invalid(error.to_string()))?;
1117        }
1118        let state = self.snapshot();
1119        validate_capture(
1120            &state.info,
1121            &state.controls,
1122            &e,
1123            self.direct && self.selection.sdk_fallback,
1124        )?;
1125        let options = self.selection.recovery.clone();
1126        let seconds = e.microseconds as f64 / 1e6;
1127        let retries = options.replacement_exposures(e.microseconds);
1128        let mut settings = self.settings();
1129        let mut prior = self
1130            .recovery_temperature
1131            .or_else(|| state.values.get(&8).map(|v| *v as f64 / 10.));
1132        let mut power = self
1133            .recovery_power
1134            .or_else(|| state.values.get(&15).copied());
1135        let mut last = None;
1136        let mut usb_resets = 0;
1137        {
1138            let mut status = self.status.lock().unwrap();
1139            status.retry.recaptures = 0;
1140            status.retry.downloads = 0;
1141            status.retry.usb_reads = 0;
1142        }
1143        for attempt in 0..=retries {
1144            let mut start_dispatched = false;
1145            let usb_reads_before = self.snapshot().retry.usb_reads;
1146            let result = self
1147                .attempt(
1148                    &e,
1149                    &mut settings,
1150                    &mut prior,
1151                    &mut power,
1152                    attempt,
1153                    &mut usb_resets,
1154                    &mut start_dispatched,
1155                    token,
1156                )
1157                .await;
1158            match result {
1159                Ok(mut frame) => {
1160                    let restored = self.worker.is_some();
1161                    frame.metadata["connectionFailuresRecovered"] = json!(if restored {
1162                        self.connection_failures
1163                    } else {
1164                        0
1165                    });
1166                    if restored {
1167                        self.connection_failures = 0;
1168                        self.reconnect_not_before = None;
1169                        self.outage_usb_reset = false;
1170                    }
1171                    {
1172                        let reads = frame.metadata["readRecoveries"]
1173                            .as_u64()
1174                            .unwrap_or(0)
1175                            .min(u32::MAX as u64) as u32;
1176                        let mut status = self.status.lock().unwrap();
1177                        // Close the diagnostic-counting window atomically with
1178                        // reconciliation: late stderr records must not count
1179                        // the same successful retry a second time.
1180                        status.retry.usb_reads = usb_reads_before.saturating_add(reads);
1181                        status.phase = "Idle".into();
1182                    }
1183                    frame.metadata["recoveries"] = json!(attempt);
1184                    frame.metadata["usbResets"] = json!(usb_resets);
1185                    let retries = self.snapshot().retry;
1186                    frame.metadata["retainedReadRetries"] = json!(retries.usb_reads);
1187                    frame.metadata["downloadRetriesTotal"] = json!(retries.downloads);
1188                    if attempt > 0 || retries.downloads > 0 || retries.usb_reads > 0 {
1189                        self.emit("info", "capture.recovered", format!(
1190                            "Returning {}x{} image after {attempt} replacement exposures, {} SDK read retries and {} retained-frame retries across all attempts; delivered frame used {} retained-frame retries and {} handle reopens",
1191                            e.width, e.height, retries.downloads, retries.usb_reads,
1192                            frame.metadata["readRecoveries"].as_u64().unwrap_or(0),
1193                            frame.metadata["handleReopens"].as_u64().unwrap_or(0)));
1194                    }
1195                    self.phase("Idle");
1196                    return Ok(frame);
1197                }
1198                Err(error) => {
1199                    if token.is_cancelled() {
1200                        // Finish framed replies before reaching this boundary.
1201                        // Acknowledged stop preserves the worker and cooler;
1202                        // an uncertain stop still retires the isolated worker.
1203                        let stopped = self
1204                            .call("stop", Value::Null, None, &CancellationToken::new())
1205                            .await;
1206                        if let Err(stop_error) = stopped {
1207                            let message = format!(
1208                                "Stop was not acknowledged; retiring worker: {stop_error:#}"
1209                            );
1210                            {
1211                                let mut state = self.status.lock().unwrap();
1212                                state.error = Some(message.clone());
1213                                state.retry.last_failure = Some(message.clone());
1214                            }
1215                            self.emit("warning", "capture.abort_failed", message);
1216                            self.invalidate().await;
1217                        } else {
1218                            self.status.lock().unwrap().error = None;
1219                            self.emit("info", "capture.aborted", "Client cancelled capture; camera stopped without reopening or replacing exposure");
1220                        }
1221                        self.phase("Aborted");
1222                        return Err(Failure::Cancelled.into());
1223                    }
1224                    {
1225                        let mut state = self.status.lock().unwrap();
1226                        state.error = Some(format!("{error:#}"));
1227                        if !token.is_cancelled() {
1228                            state.retry.last_failure = Some(format!("{error:#}"));
1229                        }
1230                        state.sdk_error_code = match error.downcast_ref::<Failure>() {
1231                            Some(
1232                                Failure::Worker { code, .. }
1233                                | Failure::UncertainControl { code, .. },
1234                            ) => *code,
1235                            _ => None,
1236                        };
1237                    }
1238                    let can_retry = start_dispatched
1239                        && retryable(&error)
1240                        && attempt < retries
1241                        && !token.is_cancelled();
1242                    self.emit(
1243                        "warning",
1244                        "capture.failed",
1245                        if start_dispatched {
1246                            format!("Attempt {}/{}: {error:#}", attempt + 1, retries + 1)
1247                        } else {
1248                            format!("Preparation failed before exposure start: {error:#}")
1249                        },
1250                    );
1251                    if retryable(&error) {
1252                        self.connection_failed();
1253                    }
1254                    self.invalidate().await;
1255                    // Retiring the worker drains its stderr. Delayed transfer
1256                    // records still contribute retry counts during that drain,
1257                    // but the final attempt error is authoritative afterward.
1258                    // Otherwise an exhausted-read detail can replace the full
1259                    // recovery failure depending on stdout/stderr scheduling.
1260                    self.status.lock().unwrap().retry.last_failure = Some(format!("{error:#}"));
1261                    if !can_retry {
1262                        last = Some(error);
1263                        break;
1264                    }
1265                    if usb_resets == 0
1266                        && !self.outage_usb_reset
1267                        && options.usb_reset_after_failures > 0
1268                        && attempt + 1 >= options.usb_reset_after_failures
1269                    {
1270                        usb_resets += 1;
1271                        self.outage_usb_reset = true;
1272                        if let Err(e) = self.reset_usb(token).await {
1273                            self.emit("error", "usb.failed", format!("{e:#}"));
1274                            last = Some(e);
1275                            break;
1276                        }
1277                    }
1278                    if self.direct && self.selection.sdk_fallback {
1279                        self.direct = false;
1280                        self.emit(
1281                            "warning",
1282                            "backend.fallback",
1283                            "Next permitted exposure retry will use SDK fallback",
1284                        );
1285                    }
1286                    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));
1287                    last = Some(error);
1288                }
1289            }
1290        }
1291        self.phase(if token.is_cancelled() {
1292            "Aborted"
1293        } else {
1294            "Error"
1295        });
1296        Err(last.unwrap_or_else(|| invalid("Capture failed")))
1297    }
1298    #[allow(clippy::too_many_arguments)] // Capture owns all mutable attempt bookkeeping.
1299    async fn attempt(
1300        &mut self,
1301        e: &Exposure,
1302        settings: &mut BTreeMap<i32, i64>,
1303        prior: &mut Option<f64>,
1304        power: &mut Option<i64>,
1305        attempt: u32,
1306        usb_resets: &mut u32,
1307        start_dispatched: &mut bool,
1308        token: &CancellationToken,
1309    ) -> Result<Frame> {
1310        let options = self.selection.recovery.clone();
1311        if token.is_cancelled() {
1312            return Err(Failure::Cancelled.into());
1313        }
1314        let preparations = if attempt == 0 {
1315            options.pre_exposure_reconnect_attempts
1316        } else {
1317            1
1318        };
1319        for preparation in 0..preparations {
1320            let result = self
1321                .prepare_exposure(e, settings, prior, power, usb_resets, token)
1322                .await;
1323            match result {
1324                Ok(()) => break,
1325                Err(error)
1326                    if retryable(&error)
1327                        && !token.is_cancelled()
1328                        && preparation + 1 < preparations =>
1329                {
1330                    self.connection_failed();
1331                    self.emit("warning", "connection.pre_exposure_retry", format!("Preparation {}/{} failed before exposure start: {error:#}; reconnecting without replacing an exposure", preparation + 1, preparations));
1332                    self.invalidate().await;
1333                }
1334                Err(error) => return Err(error),
1335            }
1336        }
1337        self.settle_required = false;
1338        self.recovery_temperature = None;
1339        self.recovery_power = None;
1340        self.recovery_target = None;
1341        self.phase("Starting exposure");
1342        let started = Utc::now();
1343        let seconds = e.microseconds as f64 / 1e6;
1344        let mut params = serde_json::to_value(e)?;
1345        if self.direct {
1346            params["readRetries"] = json!(if self.retained()
1347                || seconds <= options.maximum_retry_exposure_seconds
1348            {
1349                options.direct_read_retries
1350            } else {
1351                0
1352            });
1353            params["captureTimeoutSeconds"] =
1354                json!(self.ready_timeout(seconds) + options.command_timeout_seconds);
1355            params["transferTimeoutSeconds"] = json!(options.download_timeout_seconds);
1356            params["readChunkKiB"] = json!(options.direct_read_chunk_kib);
1357        }
1358        // Never cancel a framed pipe exchange halfway through a reply. Check
1359        // cancellation at owner checkpoints, then send a bounded stop command.
1360        let exchange_token = CancellationToken::new();
1361        if token.is_cancelled() {
1362            return Err(Failure::Cancelled.into());
1363        }
1364        // From dispatch onward, even a failed acknowledgement can mean the
1365        // sensor was armed. Only the exposure-replacement policy may retry it.
1366        *start_dispatched = true;
1367        self.status.lock().unwrap().retry.recaptures = attempt;
1368        self.call("start", params, None, &exchange_token).await?;
1369        self.phase("Exposing");
1370        let clock = Instant::now();
1371        let mut environment_sample = Instant::now();
1372        loop {
1373            if token.is_cancelled() {
1374                return Err(Failure::Cancelled.into());
1375            }
1376            self.service_cooling_with(settings, token).await?;
1377            let state = self
1378                .call("status", Value::Null, None, &exchange_token)
1379                .await?
1380                .0
1381                .as_i64()
1382                .ok_or_else(|| invalid("Invalid exposure state"))?;
1383            self.status.lock().unwrap().sdk_exposure_state = Some(state);
1384            if state == 2 {
1385                break;
1386            }
1387            ensure!(state == 1, "Exposure ended in camera state {state}");
1388            ensure!(
1389                clock.elapsed().as_secs_f64() <= self.ready_timeout(seconds),
1390                "Exposure readiness timed out"
1391            );
1392            if environment_sample.elapsed() >= Duration::from_secs(2) {
1393                self.read_environment(&exchange_token).await?;
1394                environment_sample = Instant::now();
1395            }
1396            self.delay(0.025, token).await?;
1397        }
1398        self.service_cooling_with(settings, token).await?;
1399        self.phase("Downloading");
1400        let mut reads = 0;
1401        let (mut metadata, pixels) = loop {
1402            match self
1403                .worker
1404                .as_mut()
1405                .context("Camera worker is disconnected")?
1406                .call_image(
1407                    "download",
1408                    Value::Null,
1409                    options.download_timeout_seconds,
1410                    &exchange_token,
1411                    e.bytes()?,
1412                )
1413                .await
1414            {
1415                Ok(frame) => break frame,
1416                Err(error) if !self.retained() && retryable(&error) && !token.is_cancelled() => {
1417                    self.status.lock().unwrap().retry.last_failure = Some(format!("{error:#}"));
1418                    self.emit("warning", "transfer.failed", format!("{error:#}"));
1419                    let state = self
1420                        .call("status", Value::Null, None, token)
1421                        .await?
1422                        .0
1423                        .as_i64()
1424                        .ok_or_else(|| invalid("Invalid post-transfer state"))?;
1425                    self.status.lock().unwrap().sdk_exposure_state = Some(state);
1426                    if reads >= options.ready_frame_download_retries || state != 2 {
1427                        return Err(error);
1428                    }
1429                    reads += 1;
1430                    self.status.lock().unwrap().retry.downloads += 1;
1431                    self.phase(format!(
1432                        "Rereading ready frame ({reads}/{})",
1433                        options.ready_frame_download_retries
1434                    ));
1435                    self.delay(options.reconnect_delay_seconds, token).await?;
1436                }
1437                Err(error) => return Err(error),
1438            }
1439        };
1440        if token.is_cancelled() {
1441            return Err(Failure::Cancelled.into());
1442        }
1443        {
1444            let mut state = self.status.lock().unwrap();
1445            let recovered = metadata["readRecoveries"]
1446                .as_u64()
1447                .unwrap_or(0)
1448                .min(u32::MAX as u64) as u32;
1449            if let Some(failure) = metadata["readErrors"]
1450                .as_array()
1451                .or_else(|| metadata["readoutErrors"].as_array())
1452                .and_then(|errors| errors.last())
1453                .and_then(Value::as_str)
1454            {
1455                state.retry.last_failure = Some(failure.into());
1456            } else if recovered > 0 && state.retry.usb_reads == 0 {
1457                state.retry.last_failure = Some(
1458                    "USB frame read failed; frame recovered (worker supplied no failure detail)"
1459                        .into(),
1460                );
1461            }
1462            if let Some(failure) = metadata["cleanupError"].as_str() {
1463                state.retry.last_failure = Some(failure.into());
1464            }
1465        }
1466        ensure!(
1467            pixels.len() == e.bytes()?,
1468            Failure::Invalid("Image length differs from requested ROI".into())
1469        );
1470        for (key, expected) in [("width", e.width), ("height", e.height)] {
1471            ensure!(
1472                metadata[key].as_u64() == Some(expected as u64),
1473                Failure::Invalid(format!("Frame {key} differs from request"))
1474            );
1475        }
1476        if let Some(error) = metadata["cleanupError"].as_str() {
1477            self.emit(
1478                "warning",
1479                "capture.cleanup_failed",
1480                format!("Frame preserved; reconnect required: {error}"),
1481            );
1482            self.connection_failed();
1483            self.invalidate().await;
1484        } else {
1485            self.service_cooling_with(settings, token).await?;
1486        }
1487        metadata["startedUtc"] = json!(started);
1488        if self.snapshot().white_balance.is_some() {
1489            let settings = serde_json::from_value(metadata["whiteBalance"]["settings"].clone())
1490                .map_err(|_| invalid("Missing or invalid managed white balance frame metadata"))?;
1491            self.status.lock().unwrap().white_balance = Some(settings);
1492        }
1493        metadata["endedUtc"] = json!(Utc::now());
1494        metadata["exposure"] = serde_json::to_value(e)?;
1495        metadata["controls"] = serde_json::to_value(settings)?;
1496        metadata["backend"] = json!(if self.direct { "direct" } else { "sdk" });
1497        metadata["sdkFallback"] = json!(self.selection.direct && !self.direct);
1498        metadata["downloadRetries"] = json!(reads);
1499        Ok(Frame {
1500            exposure: e.clone(),
1501            metadata,
1502            pixels: pixels.into(),
1503        })
1504    }
1505    /// All work here is before the first start dispatch. A failure can retry
1506    /// connection establishment without ever replacing an exposure. An
1507    /// unacknowledged write is deliberately excluded by retryable().
1508    async fn prepare_exposure(
1509        &mut self,
1510        e: &Exposure,
1511        settings: &mut BTreeMap<i32, i64>,
1512        prior: &mut Option<f64>,
1513        power: &mut Option<i64>,
1514        usb_resets: &mut u32,
1515        token: &CancellationToken,
1516    ) -> Result<()> {
1517        if self.worker.is_none() {
1518            self.reconnect_delay(token).await?;
1519            if !self.outage_usb_reset
1520                && *usb_resets == 0
1521                && self.selection.recovery.usb_reset_after_failures > 0
1522                && self.connection_failures >= self.selection.recovery.usb_reset_after_failures
1523            {
1524                self.outage_usb_reset = true;
1525                *usb_resets += 1;
1526                self.reset_usb(token).await?;
1527            }
1528            self.open(token).await?;
1529        }
1530        self.service_cooling_with(settings, token).await?;
1531        self.apply(settings, token).await?;
1532        if self.settle_required
1533            && settings.get(&17).copied().unwrap_or(0) != 0
1534            && let Some(t) = *prior
1535        {
1536            self.settle(t, *power, settings, token).await?;
1537        }
1538        let observed = self.read_environment(token).await?;
1539        *prior = observed.0.or(*prior);
1540        *power = observed.1.or(*power);
1541        if self.direct && self.selection.sdk_fallback {
1542            let result = self
1543                .call("validate", serde_json::to_value(e)?, None, token)
1544                .await;
1545            if let Err(error) = result {
1546                if matches!(
1547                    error.downcast_ref::<Failure>(),
1548                    Some(Failure::Worker { code: Some(8), .. })
1549                ) {
1550                    self.fallback(&error.to_string(), token).await?;
1551                    self.open(token).await?;
1552                    self.apply(settings, token).await?;
1553                    if settings.get(&17).copied().unwrap_or(0) != 0
1554                        && let Some(t) = *prior
1555                    {
1556                        self.settle(t, *power, settings, token).await?;
1557                    }
1558                } else {
1559                    return Err(error);
1560                }
1561            }
1562        }
1563        Ok(())
1564    }
1565    async fn settle(
1566        &mut self,
1567        prior: f64,
1568        prior_power: Option<i64>,
1569        settings: &mut BTreeMap<i32, i64>,
1570        token: &CancellationToken,
1571    ) -> Result<()> {
1572        let o = self.selection.recovery.clone();
1573        let clock = Instant::now();
1574        let mut stable = 0;
1575        let mut hold = CoolingHold::default();
1576        self.phase(format!("Restoring cooling near {prior:.1} C"));
1577        while clock.elapsed().as_secs_f64() < o.cooling_timeout_seconds {
1578            self.service_cooling_with(settings, token).await?;
1579            if settings.get(&17).copied().unwrap_or(0) == 0 {
1580                return Ok(());
1581            }
1582            let target = *settings.get(&16).unwrap_or(&0) as f64;
1583            let (temperature, power) = self.read_environment(token).await?;
1584            let output = prior_power.is_none_or(|p| p <= 10 || power.is_some_and(|v| v >= p - 10));
1585            let near = temperature.is_some_and(|t| {
1586                t >= prior.min(target) - o.temperature_tolerance_c
1587                    && t <= prior + o.temperature_tolerance_c
1588            });
1589            let target_held = hold.observe(
1590                temperature,
1591                power,
1592                target,
1593                o.temperature_tolerance_c,
1594                o.cooling_sample_seconds,
1595                clock.elapsed().as_secs_f64(),
1596            );
1597            stable = if (near && output) || target_held {
1598                stable + 1
1599            } else {
1600                0
1601            };
1602            self.emit("info","cooling.wait",format!("Temperature {temperature:?} C (prior {prior}), power {power:?}% (prior {prior_power:?}%), stable {stable}/{}",o.cooling_stable_samples));
1603            if stable >= o.cooling_stable_samples {
1604                self.emit(
1605                    "info",
1606                    "cooling.recovered",
1607                    format!("Cooling recovered at {temperature:?} C; restored setpoint {target} C"),
1608                );
1609                return Ok(());
1610            }
1611            self.delay(o.cooling_sample_seconds, token).await?;
1612        }
1613        anyhow::bail!("Camera did not recover its prior cooling temperature and output")
1614    }
1615    pub async fn close(&mut self) {
1616        self.usb_peripherals.retire();
1617        self.cooling.retire();
1618        self.status.lock().unwrap().connected = false;
1619        let token = CancellationToken::new();
1620        let closed = if self.worker.is_some() {
1621            match self
1622                .call("close", Value::Null, Some(CLOSE_SECONDS), &token)
1623                .await
1624            {
1625                Ok(_) => true,
1626                Err(error) => {
1627                    let message = format!("Camera close or settings restoration failed: {error:#}");
1628                    self.emit("warning", "camera.cleanup_failed", &message);
1629                    self.status.lock().unwrap().error = Some(message);
1630                    false
1631                }
1632            }
1633        } else {
1634            false
1635        };
1636        self.invalidate().await;
1637        if !closed
1638            && self.ever_opened
1639            && self
1640                .snapshot()
1641                .controls
1642                .values()
1643                .any(|c| c.writable && matches!(c.kind, 17 | 21))
1644            && self.selection.serial.is_some()
1645        {
1646            let cleanup = async {
1647                self.delay(self.selection.recovery.reconnect_delay_seconds, &token)
1648                    .await?;
1649                self.open_worker(&token, OpenPurpose::ThermalShutdown)
1650                    .await?;
1651                // Backend close disables each supported thermal actuator and
1652                // attempts both even if one write fails. Never restore settings.
1653                self.call("close", Value::Null, Some(CLOSE_SECONDS), &token)
1654                    .await?;
1655                Result::<()>::Ok(())
1656            }
1657            .await;
1658            if let Err(error) = cleanup {
1659                self.emit(
1660                    "warning",
1661                    "cooling.cleanup_failed",
1662                    format!("Could not disable cooler/dew heater: {error:#}"),
1663                );
1664            }
1665            self.invalidate().await;
1666        }
1667        self.status.lock().unwrap().connected = false;
1668        self.cooling.retire();
1669        self.usb_target = None;
1670        self.phase("Disconnected");
1671    }
1672}
1673
1674impl Drop for Session {
1675    fn drop(&mut self) {
1676        self.usb_peripherals.retire();
1677        self.cooling.retire();
1678    }
1679}
1680
1681#[derive(Default)]
1682pub struct CoolingHold {
1683    since: Option<f64>,
1684    first: f64,
1685    last: Option<f64>,
1686}
1687impl CoolingHold {
1688    pub fn observe(
1689        &mut self,
1690        temperature: Option<f64>,
1691        power: Option<i64>,
1692        target: f64,
1693        tolerance: f64,
1694        sample: f64,
1695        elapsed: f64,
1696    ) -> bool {
1697        // The caller checks that cooling is enabled before observing a hold.
1698        // Zero demand is legitimate at/below the restored setpoint; requiring
1699        // positive power here resets the hold on every idle regulator sample.
1700        let valid = temperature
1701            .is_some_and(|t| t.is_finite() && (t - target).abs() <= tolerance.min(1.))
1702            && power.is_some_and(|p| {
1703                (0..=100).contains(&p) && (p > 0 || temperature.is_some_and(|t| t <= target))
1704            });
1705        if !valid {
1706            self.since = None;
1707            self.last = None;
1708            return false;
1709        }
1710        let t = temperature.unwrap();
1711        if self.since.is_none()
1712            || self
1713                .last
1714                .is_some_and(|last| elapsed < last || elapsed - last > 5f64.max(sample * 2.))
1715            || t > self.first + 0.2
1716        {
1717            self.since = Some(elapsed);
1718            self.first = t;
1719        }
1720        self.last = Some(elapsed);
1721        elapsed - self.since.unwrap() >= 30.
1722    }
1723}
1724
1725#[cfg(test)]
1726mod tests {
1727    use super::*;
1728    #[test]
1729    fn usb_helper_preflight_requires_approved_elevation_and_valid_lifetime() {
1730        let ready = json!({"protocolVersion":1,"running":true,"elevated":true,
1731            "approved":true,
1732            "expiresAtUnixSeconds":101});
1733        validate_usb_helper_status(&ready, 100).unwrap();
1734        for key in ["running", "elevated", "approved"] {
1735            let mut status = ready.clone();
1736            status[key] = json!(false);
1737            assert!(validate_usb_helper_status(&status, 100).is_err(), "{key}");
1738            status.as_object_mut().unwrap().remove(key);
1739            assert!(
1740                validate_usb_helper_status(&status, 100).is_err(),
1741                "missing {key}"
1742            );
1743        }
1744        for expiry in [json!(100), json!(99), json!(-1), json!("101"), Value::Null] {
1745            let mut status = ready.clone();
1746            status["expiresAtUnixSeconds"] = expiry;
1747            assert!(validate_usb_helper_status(&status, 100).is_err());
1748        }
1749        let mut incompatible = ready;
1750        incompatible["protocolVersion"] = json!(2);
1751        assert!(validate_usb_helper_status(&incompatible, 100).is_err());
1752    }
1753    #[test]
1754    fn usb_helper_preflight_accepts_only_explicit_session_lifetime_or_finite_legacy_expiry() {
1755        let session = json!({"protocolVersion":1,"running":true,"elevated":true,
1756            "approved":true,"sessionScoped":true,"expiresAtUnixSeconds":null});
1757        validate_usb_helper_status(&session, 100).unwrap();
1758        validate_usb_helper_status(&session, 100 + 30 * 24 * 60 * 60).unwrap();
1759        for key in ["running", "elevated", "approved"] {
1760            let mut status = session.clone();
1761            status[key] = json!(false);
1762            assert!(validate_usb_helper_status(&status, 100).is_err(), "{key}");
1763        }
1764        for expiry in [json!(101), json!("101"), json!(false)] {
1765            let mut status = session.clone();
1766            status["expiresAtUnixSeconds"] = expiry;
1767            assert!(validate_usb_helper_status(&status, 100).is_err());
1768        }
1769        for flag in [json!(false), json!("true"), json!(1), Value::Null] {
1770            let mut status = session.clone();
1771            status["sessionScoped"] = flag;
1772            assert!(validate_usb_helper_status(&status, 100).is_err());
1773        }
1774        for key in ["sessionScoped", "expiresAtUnixSeconds"] {
1775            let mut status = session.clone();
1776            status.as_object_mut().unwrap().remove(key);
1777            assert!(validate_usb_helper_status(&status, 100).is_err());
1778        }
1779        let mut legacy = session;
1780        legacy["sessionScoped"] = json!(false);
1781        legacy["expiresAtUnixSeconds"] = json!(101);
1782        validate_usb_helper_status(&legacy, 100).unwrap();
1783        legacy.as_object_mut().unwrap().remove("sessionScoped");
1784        validate_usb_helper_status(&legacy, 100).unwrap();
1785        legacy["expiresAtUnixSeconds"] = json!(253402300800u64);
1786        assert!(validate_usb_helper_status(&legacy, 100).is_err());
1787    }
1788    fn runtime() -> Runtime {
1789        let directory = std::env::var_os("REGAIN_TEST_WORKERS")
1790            .map(std::path::PathBuf::from)
1791            .unwrap_or_else(|| {
1792                std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/debug")
1793            });
1794        Runtime {
1795            directory,
1796            sdk: "unused".into(),
1797            simulate: true,
1798            sdk_simulation: Some(json!({"instant":true})),
1799        }
1800    }
1801    fn selection(direct: bool) -> Selection {
1802        Selection {
1803            name: if direct {
1804                "ZWO ASI676MC"
1805            } else {
1806                "ZWO Simulated"
1807            }
1808            .into(),
1809            serial: None,
1810            direct,
1811            sdk_fallback: false,
1812            recovery: RecoveryOptions {
1813                reconnect_delay_seconds: 0.01,
1814                cooling_sample_seconds: 0.01,
1815                cooling_stable_samples: 1,
1816                ..RecoveryOptions::default()
1817            },
1818        }
1819    }
1820    fn exposure() -> Exposure {
1821        Exposure {
1822            width: 64,
1823            height: 64,
1824            bin: 1,
1825            x: 0,
1826            y: 0,
1827            microseconds: 10000,
1828            dark: true,
1829        }
1830    }
1831    fn log() -> Diagnostic {
1832        Arc::new(|_, _, _| {})
1833    }
1834    async fn exposing(status: &SharedStatus) {
1835        let deadline = Instant::now() + Duration::from_secs(10);
1836        loop {
1837            if status.lock().unwrap().phase == "Exposing" {
1838                return;
1839            }
1840            assert!(Instant::now() < deadline, "Capture never reached Exposing");
1841            tokio::time::sleep(Duration::from_millis(1)).await;
1842        }
1843    }
1844    #[tokio::test]
1845    async fn worker_evidence_age_survives_core_refresh_and_invalid_readback_is_uncertain() {
1846        let token = CancellationToken::new();
1847        let mut session = Session::new(selection(false), runtime(), log()).unwrap();
1848        session.connect(&token).await.unwrap();
1849        session.refresh(&token).await.unwrap();
1850        session
1851            .call(
1852                "simulation",
1853                json!({"observationControl":8,
1854            "observationReply":{"value":-100,"ageSeconds":120.25}}),
1855                None,
1856                &token,
1857            )
1858            .await
1859            .unwrap();
1860        let requested = Instant::now();
1861        session.read_environment(&token).await.unwrap();
1862        let received = Instant::now();
1863        let observed = session.snapshot().observations[&8];
1864        assert_eq!(observed.value, -100);
1865        // The internal request admission lies between these two boundaries.
1866        let age = Duration::from_secs_f64(120.25);
1867        assert!(observed.observed_at >= requested - age);
1868        assert!(observed.observed_at <= received - age);
1869        let previous = session.snapshot().observations[&0];
1870        session
1871            .call(
1872                "simulation",
1873                json!({"observationControl":0,
1874            "observationReply":{"value":123,"ageSeconds":-1}}),
1875                None,
1876                &token,
1877            )
1878            .await
1879            .unwrap();
1880        let error = session
1881            .set_imaging_control(0, 123, Instant::now() + Duration::from_secs(5), &token)
1882            .await
1883            .unwrap_err();
1884        assert!(matches!(
1885            error.downcast_ref::<Failure>(),
1886            Some(Failure::UncertainControl { .. })
1887        ));
1888        assert!(!retryable(&error));
1889        assert_eq!(session.snapshot().observations[&0], previous);
1890        assert!(!session.snapshot().control_connection_available);
1891        assert!(session.worker.is_none());
1892        session.close().await;
1893    }
1894    #[tokio::test]
1895    async fn queued_persistent_control_invalid_observation_never_replays_or_starts_exposure() {
1896        for (kind, value) in [(0, 123), (5, 20), (16, -15), (17, 0)] {
1897            let token = CancellationToken::new();
1898            let events = Arc::new(Mutex::new(Vec::new()));
1899            let sink = events.clone();
1900            let mut session = Session::new(
1901                selection(false),
1902                runtime(),
1903                Arc::new(move |_, event, _| sink.lock().unwrap().push(event.to_owned())),
1904            )
1905            .unwrap();
1906            session.connect(&token).await.unwrap();
1907            session.refresh(&token).await.unwrap();
1908            let previous = session.snapshot().observations[&kind];
1909            assert_ne!(
1910                previous.value, value,
1911                "The test must dispatch a changed setting"
1912            );
1913            let writes = events
1914                .lock()
1915                .unwrap()
1916                .iter()
1917                .filter(|event| event.as_str() == "control.write_acknowledged")
1918                .count();
1919            session
1920                .call(
1921                    "simulation",
1922                    json!({"observationControl":kind,
1923                "observationReply":{"value":value,"ageSeconds":-1}}),
1924                    None,
1925                    &token,
1926                )
1927                .await
1928                .unwrap();
1929            Session::queue_control(&session.status, kind, value).unwrap();
1930            let error = session.capture(exposure(), &token).await.err().unwrap();
1931            assert!(matches!(
1932                error.downcast_ref::<Failure>(),
1933                Some(Failure::UncertainControl { .. })
1934            ));
1935            assert!(!retryable(&error));
1936            assert_eq!(session.snapshot().observations[&kind], previous);
1937            assert!(!session.snapshot().control_connection_available);
1938            assert!(session.worker.is_none());
1939            let observed = events.lock().unwrap().clone();
1940            assert_eq!(
1941                observed
1942                    .iter()
1943                    .filter(|event| event.as_str() == "control.write_acknowledged")
1944                    .count(),
1945                writes + 1
1946            );
1947            assert_eq!(
1948                observed
1949                    .iter()
1950                    .filter(|event| event.as_str() == "connection.opened")
1951                    .count(),
1952                1
1953            );
1954            assert!(!observed.iter().any(|event| event == "capture.retry"));
1955            assert_ne!(session.snapshot().phase, "Exposing");
1956            session.close().await;
1957        }
1958    }
1959    #[tokio::test]
1960    async fn idle_environment_read_never_applies_queued_settings_or_reopens_lost_worker() {
1961        for direct in [false, true] {
1962            let token = CancellationToken::new();
1963            let mut selected = selection(direct);
1964            if direct {
1965                selected.name = "ZWO ASI585MM Pro".into();
1966            }
1967            let mut session = Session::new(selected, runtime(), log()).unwrap();
1968            session.connect(&token).await.unwrap();
1969            session.refresh(&token).await.unwrap();
1970            let before = session.snapshot();
1971            Session::queue_control(&session.status, 0, 234).unwrap();
1972            Session::queue_control(&session.status, 16, -20).unwrap();
1973            session.refresh_environment(&token).await.unwrap();
1974            let after = session.snapshot();
1975            assert_eq!(after.process_id, before.process_id);
1976            for kind in [0, 5, 16, 17] {
1977                assert_eq!(after.observations[&kind], before.observations[&kind]);
1978                assert_eq!(
1979                    session
1980                        .call("get", json!({"control":kind}), None, &token)
1981                        .await
1982                        .unwrap()
1983                        .0,
1984                    before.observations[&kind].value
1985                );
1986            }
1987            assert_eq!(after.values[&0], 234);
1988            assert_eq!(after.values[&16], -20);
1989            for kind in [8, 15] {
1990                assert!(
1991                    after.observations[&kind].observed_at > before.observations[&kind].observed_at
1992                );
1993            }
1994            session.invalidate().await;
1995            assert!(session.refresh_environment(&token).await.is_err());
1996            assert!(session.worker.is_none());
1997            assert!(session.snapshot().process_id.is_none());
1998            assert_eq!(session.snapshot().observations, after.observations);
1999            session.close().await;
2000        }
2001    }
2002    #[tokio::test]
2003    async fn failed_idle_environment_read_retires_worker_without_reopening_or_applying_settings() {
2004        let token = CancellationToken::new();
2005        let mut session = Session::new(selection(false), runtime(), log()).unwrap();
2006        session.connect(&token).await.unwrap();
2007        session.refresh(&token).await.unwrap();
2008        let before = session.snapshot();
2009        Session::queue_control(&session.status, 0, 234).unwrap();
2010        session
2011            .call(
2012                "simulation",
2013                json!({"observationControl":8,
2014            "observationReply":{"value":100,"ageSeconds":-1}}),
2015                None,
2016                &token,
2017            )
2018            .await
2019            .unwrap();
2020        assert!(session.refresh_environment(&token).await.is_err());
2021        assert!(!session.snapshot().control_connection_available);
2022        assert!(session.worker.is_none());
2023        assert_eq!(session.snapshot().observations, before.observations);
2024        assert_eq!(session.snapshot().values[&0], 234);
2025        assert!(session.refresh_environment(&token).await.is_err());
2026        assert!(session.worker.is_none());
2027        assert_eq!(session.snapshot().observations, before.observations);
2028        session.close().await;
2029    }
2030    #[tokio::test]
2031    async fn observations_are_acknowledged_evidence_not_desired_settings_or_new_worker_defaults() {
2032        for direct in [false, true] {
2033            let token = CancellationToken::new();
2034            let mut selected = selection(direct);
2035            if direct {
2036                selected.name = "ZWO ASI585MM Pro".into();
2037            }
2038            let mut session = Session::new(selected, runtime(), log()).unwrap();
2039            session.connect(&token).await.unwrap();
2040            assert!(session.snapshot().observations.is_empty());
2041            Session::queue_control(&session.status, 0, 123).unwrap();
2042            assert!(session.snapshot().observations.is_empty());
2043            session.refresh(&token).await.unwrap();
2044            let observed = session.snapshot().observations;
2045            for kind in [0, 5, 8, 15, 16, 17] {
2046                assert!(observed[&kind].observed_at <= Instant::now());
2047            }
2048            assert_eq!(observed[&0].value, 123);
2049            Session::queue_control(&session.status, 0, 234).unwrap();
2050            let queued = session.snapshot();
2051            assert_eq!(queued.values[&0], 234);
2052            assert_eq!(queued.observations[&0], observed[&0]);
2053            let json = serde_json::to_value(&queued).unwrap();
2054            assert!(json.get("observations").is_none());
2055            assert!(json.to_string().find("observedAt").is_none());
2056            session.refresh(&token).await.unwrap();
2057            let acknowledged = session.snapshot().observations[&0];
2058            assert_eq!(acknowledged.value, 234);
2059            assert!(acknowledged.observed_at >= observed[&0].observed_at);
2060            session.invalidate().await;
2061            // Diagnostics can retain known aged evidence while unavailable.
2062            assert_eq!(session.snapshot().observations[&0], acknowledged);
2063            session.open(&token).await.unwrap();
2064            assert!(session.snapshot().observations.is_empty());
2065            assert_eq!(session.snapshot().values[&0], 234);
2066            session.refresh(&token).await.unwrap();
2067            assert_eq!(session.snapshot().observations[&0].value, 234);
2068            session.close().await;
2069        }
2070    }
2071    #[tokio::test]
2072    async fn acknowledged_imaging_controls_apply_before_success_and_survive_worker_recovery() {
2073        for direct in [false, true] {
2074            let token = CancellationToken::new();
2075            let mut session = Session::new(selection(direct), runtime(), log()).unwrap();
2076            session.connect(&token).await.unwrap();
2077            session.refresh(&token).await.unwrap();
2078            for (kind, value) in [(0, 123), (5, 20)] {
2079                assert_eq!(
2080                    session
2081                        .set_imaging_control(
2082                            kind,
2083                            value,
2084                            Instant::now() + Duration::from_secs(5),
2085                            &token
2086                        )
2087                        .await
2088                        .unwrap(),
2089                    value
2090                );
2091                assert_eq!(session.snapshot().values[&kind], value);
2092                let observation = session.snapshot().observations[&kind];
2093                assert_eq!(observation.value, value);
2094                assert!(observation.observed_at <= Instant::now());
2095                assert_eq!(session.applied[&kind], value);
2096                assert_eq!(
2097                    session
2098                        .call("get", json!({"control":kind}), None, &token)
2099                        .await
2100                        .unwrap()
2101                        .0,
2102                    value
2103                );
2104            }
2105            session.invalidate().await;
2106            let frame = session.capture(exposure(), &token).await.unwrap();
2107            assert_eq!(frame.metadata["controls"]["0"], 123);
2108            assert_eq!(frame.metadata["controls"]["5"], 20);
2109            for (kind, value) in [(0, 123), (5, 20)] {
2110                assert_eq!(
2111                    session
2112                        .call("get", json!({"control":kind}), None, &token)
2113                        .await
2114                        .unwrap()
2115                        .0,
2116                    value
2117                );
2118            }
2119            session.close().await;
2120        }
2121    }
2122    #[tokio::test]
2123    async fn imaging_control_mismatch_retires_worker_without_publishing_or_retrying() {
2124        let token = CancellationToken::new();
2125        let mut rt = runtime();
2126        rt.sdk_simulation = Some(json!({"instant":true,"clampControl":0,"clampMinimum":200}));
2127        let mut session = Session::new(selection(false), rt, log()).unwrap();
2128        session.connect(&token).await.unwrap();
2129        let before = session.snapshot().values[&0];
2130        let error = session
2131            .set_imaging_control(0, 100, Instant::now() + Duration::from_secs(5), &token)
2132            .await
2133            .unwrap_err();
2134        assert!(matches!(
2135            error.downcast_ref::<Failure>(),
2136            Some(Failure::UncertainControl { .. })
2137        ));
2138        assert!(!retryable(&error));
2139        assert_eq!(session.snapshot().values[&0], before);
2140        assert!(!session.snapshot().control_connection_available);
2141        assert!(session.worker.is_none());
2142        assert!(session.applied.is_empty());
2143        session.close().await;
2144    }
2145    #[tokio::test]
2146    async fn acknowledged_sdk_offset_preserves_existing_bounded_clamp_policy() {
2147        let token = CancellationToken::new();
2148        let mut rt = runtime();
2149        rt.sdk_simulation = Some(json!({"instant":true,"clampControl":5,"clampMinimum":20}));
2150        let mut session = Session::new(selection(false), rt, log()).unwrap();
2151        session.connect(&token).await.unwrap();
2152        assert_eq!(
2153            session
2154                .set_imaging_control(5, 0, Instant::now() + Duration::from_secs(5), &token)
2155                .await
2156                .unwrap(),
2157            20
2158        );
2159        assert_eq!(session.snapshot().values[&5], 20);
2160        assert_eq!(session.applied[&5], 20);
2161        let frame = session.capture(exposure(), &token).await.unwrap();
2162        assert_eq!(frame.metadata["controls"]["5"], 20);
2163        let maximum = session.snapshot().controls[&5].max;
2164        session
2165            .call(
2166                "simulation",
2167                json!({"clampControl":5,"clampMinimum":maximum+1}),
2168                None,
2169                &token,
2170            )
2171            .await
2172            .unwrap();
2173        let error = session
2174            .set_imaging_control(5, 0, Instant::now() + Duration::from_secs(5), &token)
2175            .await
2176            .unwrap_err();
2177        assert!(matches!(
2178            error.downcast_ref::<Failure>(),
2179            Some(Failure::UncertainControl { .. })
2180        ));
2181        assert_eq!(session.snapshot().values[&5], 20);
2182        assert!(session.worker.is_none());
2183        session.close().await;
2184    }
2185    #[tokio::test]
2186    async fn imaging_control_preflight_rejects_without_io_or_desired_state_changes() {
2187        let token = CancellationToken::new();
2188        let mut session = Session::new(selection(false), runtime(), log()).unwrap();
2189        session.connect(&token).await.unwrap();
2190        session.refresh(&token).await.unwrap();
2191        let before = session.snapshot();
2192        let applied = session.applied.clone();
2193        for (kind, value) in [(1, 1000), (16, -10), (0, before.controls[&0].max + 1)] {
2194            let error = session
2195                .set_imaging_control(kind, value, Instant::now() + Duration::from_secs(5), &token)
2196                .await
2197                .unwrap_err();
2198            assert!(matches!(
2199                error.downcast_ref::<Failure>(),
2200                Some(Failure::Invalid(_))
2201            ));
2202        }
2203        for malformed in 0..4 {
2204            {
2205                let mut state = session.status.lock().unwrap();
2206                let cap = state.controls.get_mut(&0).unwrap();
2207                match malformed {
2208                    0 => cap.kind = 5,
2209                    1 => cap.min = cap.max + 1,
2210                    2 => cap.writable = false,
2211                    _ => {
2212                        state.controls.remove(&0);
2213                    }
2214                }
2215            }
2216            assert!(
2217                session
2218                    .set_imaging_control(0, 123, Instant::now() + Duration::from_secs(5), &token)
2219                    .await
2220                    .is_err()
2221            );
2222            session
2223                .status
2224                .lock()
2225                .unwrap()
2226                .controls
2227                .insert(0, before.controls[&0].clone());
2228        }
2229        let cancelled = CancellationToken::new();
2230        cancelled.cancel();
2231        let error = session
2232            .set_imaging_control(0, 123, Instant::now() + Duration::from_secs(5), &cancelled)
2233            .await
2234            .unwrap_err();
2235        assert!(matches!(
2236            error.downcast_ref::<Failure>(),
2237            Some(Failure::Cancelled)
2238        ));
2239        let error = session
2240            .set_imaging_control(0, 123, Instant::now(), &token)
2241            .await
2242            .unwrap_err();
2243        assert!(matches!(
2244            error.downcast_ref::<cooling::CoolingError>(),
2245            Some(cooling::CoolingError::Expired)
2246        ));
2247        let cooler = session
2248            .cooling()
2249            .submit(16, -15, Duration::from_secs(5))
2250            .unwrap();
2251        assert!(
2252            session
2253                .set_imaging_control(0, 123, Instant::now() + Duration::from_secs(5), &token)
2254                .await
2255                .is_err()
2256        );
2257        drop(cooler);
2258        assert_eq!(session.snapshot().values, before.values);
2259        assert_eq!(session.applied, applied);
2260        assert_eq!(session.snapshot().process_id, before.process_id);
2261        for kind in [0, 5] {
2262            assert_eq!(
2263                session
2264                    .call("get", json!({"control":kind}), None, &token)
2265                    .await
2266                    .unwrap()
2267                    .0,
2268                before.values[&kind]
2269            );
2270        }
2271        session.close().await;
2272    }
2273    async fn acknowledged_cooling(direct: bool) {
2274        let token = CancellationToken::new();
2275        let mut sel = selection(direct);
2276        if direct {
2277            sel.name = "ZWO ASI585MM Pro".into();
2278        }
2279        sel.recovery.ready_frame_download_retries = 0;
2280        let mut rt = runtime();
2281        rt.sdk_simulation = Some(json!({"instant":false}));
2282        let mut session = Session::new(sel, rt, log()).unwrap();
2283        session.connect(&token).await.unwrap();
2284        let handle = session.cooling();
2285        let shared = session.status.clone();
2286        let original = shared.lock().unwrap().values[&16];
2287        Session::queue_control(&shared, 0, 123).unwrap();
2288        if !direct {
2289            session
2290                .call("fault", json!({"kind":"download"}), None, &token)
2291                .await
2292                .unwrap();
2293        }
2294        let capture = session.capture(
2295            Exposure {
2296                microseconds: 6_000_000,
2297                ..exposure()
2298            },
2299            &token,
2300        );
2301        let commands = async {
2302            exposing(&shared).await;
2303            let first = handle.submit(16, -10, Duration::from_secs(5)).unwrap();
2304            assert_eq!(shared.lock().unwrap().values[&16], original);
2305            assert_eq!(first.wait().await, Ok(-10));
2306            for (kind, value) in [(17, 1), (16, -15)] {
2307                assert_eq!(
2308                    handle
2309                        .submit(kind, value, Duration::from_secs(5))
2310                        .unwrap()
2311                        .wait()
2312                        .await,
2313                    Ok(value)
2314                );
2315            }
2316            drop(handle.submit(16, -20, Duration::from_secs(5)).unwrap());
2317            // Legacy deferred imaging intent must not change frozen capture or
2318            // replacement settings. Only acknowledged cooler keys are live.
2319            Session::queue_control(&shared, 0, 200).unwrap();
2320        };
2321        let (frame, ()) = tokio::join!(capture, commands);
2322        let frame = frame.unwrap();
2323        assert_eq!(frame.metadata["controls"]["16"], -15);
2324        assert_eq!(frame.metadata["controls"]["17"], 1);
2325        assert_eq!(frame.metadata["controls"]["0"], 123);
2326        assert_eq!(frame.metadata["recoveries"], if direct { 0 } else { 1 });
2327        assert_eq!(
2328            frame.pixels.as_ref(),
2329            (0..4096u16).flat_map(u16::to_le_bytes).collect::<Vec<_>>()
2330        );
2331        assert_eq!(session.snapshot().values[&16], -15);
2332        assert_eq!(session.snapshot().values[&17], 1);
2333        assert!(!handle.pending());
2334        session.invalidate().await;
2335        // A later capture restores the acknowledged target after worker loss.
2336        let next = session.capture(exposure(), &token).await.unwrap();
2337        assert_eq!(next.metadata["controls"]["16"], -15);
2338        assert_eq!(next.metadata["controls"]["17"], 1);
2339        assert_eq!(next.metadata["controls"]["0"], 200);
2340        session.close().await;
2341    }
2342    #[tokio::test]
2343    async fn acknowledged_sdk_cooling_survives_replacement_and_preserves_imaging_settings() {
2344        acknowledged_cooling(false).await;
2345    }
2346    #[tokio::test]
2347    async fn scalar_persistent_control_readback_failure_never_replays_or_arms() {
2348        let token = CancellationToken::new();
2349        let events = Arc::new(Mutex::new(Vec::<String>::new()));
2350        let sink = events.clone();
2351        let mut session = Session::new(
2352            selection(false),
2353            runtime(),
2354            Arc::new(move |_, event, message| {
2355                sink.lock().unwrap().push(
2356                    if event == "session.phase" && message == "Starting exposure" {
2357                        "test.start".into()
2358                    } else {
2359                        event.into()
2360                    },
2361                );
2362            }),
2363        )
2364        .unwrap();
2365        session.connect(&token).await.unwrap();
2366        session.refresh(&token).await.unwrap();
2367        // USB traffic uses scalar get, outside the four original timed
2368        // observation controls. Its write must not be repeated after an
2369        // acknowledged set whose readback cannot confirm the requested value.
2370        session
2371            .call(
2372                "simulation",
2373                json!({"clampControl":6,"clampMinimum":52}),
2374                None,
2375                &token,
2376            )
2377            .await
2378            .unwrap();
2379        Session::queue_control(&session.status, 6, 51).unwrap();
2380        events.lock().unwrap().clear();
2381        let error = session.capture(exposure(), &token).await.err().unwrap();
2382        assert!(matches!(
2383            error.downcast_ref::<Failure>(),
2384            Some(Failure::UncertainControl { .. })
2385        ));
2386        assert!(!retryable(&error));
2387        assert!(session.worker.is_none());
2388        let recorded = events.lock().unwrap().clone();
2389        assert_eq!(
2390            recorded
2391                .iter()
2392                .filter(|e| *e == "control.write_acknowledged")
2393                .count(),
2394            1
2395        );
2396        assert!(
2397            !recorded.iter().any(|e| e == "connection.pre_exposure_retry"
2398                || e == "capture.retry"
2399                || e == "test.start")
2400        );
2401        session.close().await;
2402    }
2403    #[tokio::test]
2404    async fn acknowledged_direct_cooling_survives_worker_recovery() {
2405        acknowledged_cooling(true).await;
2406    }
2407    #[tokio::test]
2408    async fn cooler_readback_mismatch_stops_capture_without_retry_or_target_publication() {
2409        let token = CancellationToken::new();
2410        let mut rt = runtime();
2411        rt.sdk_simulation = Some(json!({"instant":false}));
2412        let events = Arc::new(Mutex::new(Vec::new()));
2413        let sink = events.clone();
2414        let mut session = Session::new(
2415            selection(false),
2416            rt,
2417            Arc::new(move |_, event, _| sink.lock().unwrap().push(event.to_owned())),
2418        )
2419        .unwrap();
2420        session.connect(&token).await.unwrap();
2421        session.refresh(&token).await.unwrap();
2422        session
2423            .call(
2424                "simulation",
2425                json!({"clampControl":16,"clampMinimum":5}),
2426                None,
2427                &token,
2428            )
2429            .await
2430            .unwrap();
2431        let handle = session.cooling();
2432        let shared = session.status.clone();
2433        let original = shared.lock().unwrap().values[&16];
2434        let capture = session.capture(
2435            Exposure {
2436                microseconds: 6_000_000,
2437                ..exposure()
2438            },
2439            &token,
2440        );
2441        let commands = async {
2442            exposing(&shared).await;
2443            assert!(matches!(
2444                handle
2445                    .submit(16, -10, Duration::from_secs(5))
2446                    .unwrap()
2447                    .wait()
2448                    .await,
2449                Err(cooling::CoolingError::Uncertain { .. })
2450            ));
2451        };
2452        let (result, ()) = tokio::join!(capture, commands);
2453        let error = result.err().unwrap();
2454        assert!(matches!(
2455            error.downcast_ref::<Failure>(),
2456            Some(Failure::UncertainControl { .. })
2457        ));
2458        assert!(!retryable(&error));
2459        assert_eq!(session.snapshot().values[&16], original);
2460        assert_eq!(session.snapshot().observations[&16].value, original);
2461        assert!(!session.snapshot().control_connection_available);
2462        assert!(
2463            !events
2464                .lock()
2465                .unwrap()
2466                .iter()
2467                .any(|event| event == "capture.retry")
2468        );
2469        assert!(matches!(
2470            handle.submit(17, 1, Duration::from_secs(1)),
2471            Err(cooling::CoolingError::Unavailable)
2472        ));
2473        session.close().await;
2474    }
2475    #[tokio::test]
2476    async fn idle_cooling_acknowledges_and_teardown_rejects_queued_requests() {
2477        for direct in [false, true] {
2478            let token = CancellationToken::new();
2479            let mut sel = selection(direct);
2480            if direct {
2481                sel.name = "ZWO ASI585MM Pro".into();
2482            }
2483            let mut session = Session::new(sel, runtime(), log()).unwrap();
2484            session.connect(&token).await.unwrap();
2485            let handle = session.cooling();
2486            let receipt = handle.submit(16, -10, Duration::from_secs(5)).unwrap();
2487            session.service_cooling(&token).await.unwrap();
2488            assert_eq!(receipt.wait().await, Ok(-10));
2489            assert_eq!(session.snapshot().values[&16], -10);
2490            let receipt = handle.submit(16, -20, Duration::from_secs(5)).unwrap();
2491            session.close().await;
2492            assert_eq!(
2493                receipt.wait().await,
2494                Err(cooling::CoolingError::Unavailable)
2495            );
2496            assert!(!handle.pending());
2497        }
2498    }
2499    #[tokio::test]
2500    async fn live_cooler_target_and_disable_are_acknowledged_during_recovery_settle() {
2501        for direct in [false, true] {
2502            let token = CancellationToken::new();
2503            let mut sel = selection(direct);
2504            if direct {
2505                sel.name = "ZWO ASI585MM Pro".into();
2506            }
2507            let events = Arc::new(Mutex::new(Vec::new()));
2508            let recorded = events.clone();
2509            let mut session = Session::new(
2510                sel,
2511                runtime(),
2512                Arc::new(move |_, event, _| {
2513                    recorded.lock().unwrap().push(event.to_string());
2514                }),
2515            )
2516            .unwrap();
2517            session.connect(&token).await.unwrap();
2518            let handle = session.cooling();
2519            let shared = session.status.clone();
2520            let enabled = handle.submit(17, 1, Duration::from_secs(5)).unwrap();
2521            session.service_cooling(&token).await.unwrap();
2522            assert_eq!(enabled.wait().await, Ok(1));
2523            // Neither simulator meets this prior temperature/output, so settle
2524            // must remain active until the caller explicitly disables cooling.
2525            session.seed_recovery(Some(-30.), Some(80));
2526            let capture = session.capture(exposure(), &token);
2527            let commands = async {
2528                let deadline = Instant::now() + Duration::from_secs(10);
2529                loop {
2530                    if shared
2531                        .lock()
2532                        .unwrap()
2533                        .phase
2534                        .starts_with("Restoring cooling")
2535                    {
2536                        break;
2537                    }
2538                    assert!(Instant::now() < deadline);
2539                    tokio::time::sleep(Duration::from_millis(1)).await;
2540                }
2541                for (kind, value) in [(16, -15), (17, 0)] {
2542                    assert_eq!(
2543                        handle
2544                            .submit(kind, value, Duration::from_secs(5))
2545                            .unwrap()
2546                            .wait()
2547                            .await,
2548                        Ok(value)
2549                    );
2550                }
2551            };
2552            let (frame, ()) = tokio::join!(capture, commands);
2553            let frame = frame.unwrap();
2554            assert_eq!(frame.metadata["controls"]["16"], -15);
2555            assert_eq!(frame.metadata["controls"]["17"], 0);
2556            assert_eq!(frame.metadata["recoveries"], 0);
2557            assert!(!handle.pending());
2558            assert!(
2559                !events
2560                    .lock()
2561                    .unwrap()
2562                    .iter()
2563                    .any(|event| event == "cooling.recovered")
2564            );
2565            session.close().await;
2566        }
2567    }
2568    #[tokio::test]
2569    async fn direct_usb_read_size_survives_worker_replacement_and_leaves_sdk_bandwidth_unchanged() {
2570        let token = CancellationToken::new();
2571        let mut sel = selection(true);
2572        sel.recovery.direct_read_chunk_kib = 64;
2573        let mut s = Session::new(sel, runtime(), log()).unwrap();
2574        s.connect(&token).await.unwrap();
2575        assert!(!s.snapshot().controls[&6].writable);
2576        assert_eq!(s.snapshot().values[&6], 40);
2577        assert_eq!(
2578            s.capture(exposure(), &token).await.unwrap().metadata["readChunkKiB"],
2579            64
2580        );
2581        s.call("simulate-read-failures", json!({"count":3}), None, &token)
2582            .await
2583            .unwrap();
2584        let frame = s.capture(exposure(), &token).await.unwrap();
2585        assert_eq!(frame.metadata["recoveries"], 1);
2586        assert_eq!(frame.metadata["readChunkKiB"], 64);
2587        s.close().await;
2588    }
2589    #[test]
2590    fn direct_usb_read_size_configuration_preserves_key_default_and_bounds() {
2591        let old: RecoveryOptions = serde_json::from_value(json!({"directReadRetries":1})).unwrap();
2592        assert_eq!(old.direct_read_chunk_kib, 1024);
2593        let current: RecoveryOptions =
2594            serde_json::from_value(json!({"directReadChunkKiB":64})).unwrap();
2595        assert_eq!(current.direct_read_chunk_kib, 64);
2596        assert_eq!(
2597            serde_json::to_value(current).unwrap()["directReadChunkKiB"],
2598            64
2599        );
2600        for value in [0, 3, 512, 1024, 2048, u32::MAX] {
2601            let options = RecoveryOptions {
2602                direct_read_chunk_kib: value,
2603                ..RecoveryOptions::default()
2604            };
2605            assert_eq!(options.validate().is_ok(), matches!(value, 512 | 1024));
2606        }
2607    }
2608    #[tokio::test]
2609    async fn cooled_worker_recovery_restores_output_once_and_honors_warming_or_disable() {
2610        let token = CancellationToken::new();
2611        let mut sel = selection(true);
2612        sel.name = "ZWO ASI585MM Pro".into();
2613        let mut s = Session::new(sel, runtime(), log()).unwrap();
2614        s.connect(&token).await.unwrap();
2615        Session::queue_control(&s.status, 16, 10).unwrap();
2616        Session::queue_control(&s.status, 17, 1).unwrap();
2617        s.refresh(&token).await.unwrap();
2618        s.call(
2619            "resume-cooling",
2620            json!({"power":40,"temperature":25.0,"previousTarget":10}),
2621            None,
2622            &token,
2623        )
2624        .await
2625        .unwrap();
2626        s.refresh(&token).await.unwrap();
2627        assert_eq!(s.snapshot().values[&15], 40);
2628        s.call("simulate-read-failures", json!({"count":3}), None, &token)
2629            .await
2630            .unwrap();
2631        let frame = s.capture(exposure(), &token).await.unwrap();
2632        assert_eq!(frame.metadata["recoveries"], 1);
2633        assert_eq!(s.snapshot().values[&15], 40);
2634        assert!(s.cooling_seeded);
2635        // Ordinary refreshes must not repeatedly reseed the regulator.
2636        s.call(
2637            "resume-cooling",
2638            json!({"power":30,"temperature":25.0,"previousTarget":10}),
2639            None,
2640            &token,
2641        )
2642        .await
2643        .unwrap();
2644        s.refresh(&token).await.unwrap();
2645        assert_eq!(s.snapshot().values[&15], 30);
2646        s.invalidate().await;
2647        Session::queue_control(&s.status, 16, 11).unwrap();
2648        assert!(s.refresh(&token).await.is_err());
2649        s.open(&token).await.unwrap();
2650        s.apply(&mut s.settings(), &token).await.unwrap();
2651        assert_eq!(s.snapshot().values[&15], 0); // A warmer requested target wins.
2652        s.invalidate().await;
2653        Session::queue_control(&s.status, 17, 0).unwrap();
2654        assert!(s.refresh(&token).await.is_err());
2655        s.open(&token).await.unwrap();
2656        s.apply(&mut s.settings(), &token).await.unwrap();
2657        assert_eq!(s.snapshot().values[&17], 0);
2658        assert!(!s.cooling_seeded);
2659        s.close().await;
2660    }
2661
2662    #[tokio::test]
2663    async fn managed_white_balance_survives_worker_recovery_and_retains_locked_gains() {
2664        use crate::white_balance::{Gains, Mode, Output, Settings};
2665        for direct in [false, true] {
2666            let token = CancellationToken::new();
2667            let mut s = Session::new(selection(direct), runtime(), log()).unwrap();
2668            s.connect(&token).await.unwrap();
2669            assert_eq!(s.snapshot().white_balance_capabilities["supported"], true);
2670            let settings = Settings {
2671                mode: Mode::Manual,
2672                gains: Gains { red: 2., blue: 0.5 },
2673                output: Output::Corrected,
2674            };
2675            s.set_white_balance(settings, &token).await.unwrap();
2676            s.set_white_balance(
2677                Settings {
2678                    mode: Mode::Locked,
2679                    ..settings
2680                },
2681                &token,
2682            )
2683            .await
2684            .unwrap();
2685            assert!(Session::queue_control(&s.status, 3, 50).is_err());
2686            let e = Exposure {
2687                dark: false,
2688                ..exposure()
2689            };
2690            let first = s.capture(e.clone(), &token).await.unwrap();
2691            assert_eq!(first.metadata["whiteBalance"]["applied"], true);
2692            s.invalidate().await;
2693            let recovered = s.capture(e, &token).await.unwrap();
2694            assert_eq!(first.pixels, recovered.pixels);
2695            assert_eq!(
2696                s.snapshot().white_balance.unwrap(),
2697                Settings {
2698                    mode: Mode::Locked,
2699                    ..settings
2700                }
2701            );
2702            s.set_white_balance(
2703                Settings {
2704                    mode: Mode::Once,
2705                    ..Settings::default()
2706                },
2707                &token,
2708            )
2709            .await
2710            .unwrap();
2711            s.capture(
2712                Exposure {
2713                    dark: false,
2714                    ..exposure()
2715                },
2716                &token,
2717            )
2718            .await
2719            .unwrap();
2720            let effective = s.snapshot().white_balance.unwrap();
2721            assert_eq!(effective.mode, Mode::Locked);
2722            s.invalidate().await;
2723            s.capture(exposure(), &token).await.unwrap();
2724            assert_eq!(s.snapshot().white_balance.unwrap(), effective);
2725            s.invalidate().await;
2726        }
2727    }
2728    #[test]
2729    fn cooling_requires_output_or_sustained_setpoint_and_resets_on_warming_or_gaps() {
2730        let mut hold = CoolingHold::default();
2731        for i in 0..=15 {
2732            assert_eq!(
2733                hold.observe(Some(-10.), Some(20), -10., 2., 2., i as f64 * 2.),
2734                i == 15
2735            );
2736        }
2737        assert!(!hold.observe(Some(-9.7), Some(20), -10., 2., 2., 32.));
2738        assert!(!hold.observe(Some(-10.), Some(20), -10., 2., 2., 100.));
2739        assert!(!hold.observe(Some(-10.), Some(0), -10., 2., 2., 102.));
2740    }
2741    #[test]
2742    fn cooling_setpoint_hold_accepts_recorded_zero_to_four_percent_duty_cycle() {
2743        let mut hold = CoolingHold::default();
2744        // Replay the reported temperatures/duty cycle at a restored 30 C
2745        // setpoint: zero demand must not discard sustained regulation.
2746        let samples = [
2747            (29.8, 4),
2748            (29.6, 3),
2749            (29.6, 3),
2750            (29.5, 2),
2751            (29.4, 1),
2752            (29.3, 0),
2753            (29.3, 0),
2754            (29.5, 1),
2755            (29.5, 1),
2756            (29.5, 1),
2757            (29.5, 1),
2758            (29.4, 0),
2759            (29.5, 0),
2760            (29.5, 1),
2761            (29.6, 2),
2762            (29.5, 1),
2763            (29.5, 0),
2764            (29.5, 0),
2765        ];
2766        for (index, (temperature, power)) in samples.into_iter().enumerate() {
2767            assert_eq!(
2768                hold.observe(
2769                    Some(temperature),
2770                    Some(power),
2771                    30.,
2772                    2.,
2773                    2.,
2774                    index as f64 * 2.
2775                ),
2776                index >= 15,
2777                "sample {index}"
2778            );
2779        }
2780    }
2781    #[test]
2782    fn cooling_zero_power_hold_requires_the_restored_target_and_full_duration() {
2783        for (temperature, target) in [(29.5, -5.), (-6.1, -5.), (-4.9, -5.)] {
2784            let mut hold = CoolingHold::default();
2785            for index in 0..=20 {
2786                assert!(!hold.observe(
2787                    Some(temperature),
2788                    Some(0),
2789                    target,
2790                    2.,
2791                    2.,
2792                    index as f64 * 2.
2793                ));
2794            }
2795        }
2796        let mut hold = CoolingHold::default();
2797        for index in 0..=15 {
2798            assert_eq!(
2799                hold.observe(Some(-5.), Some(0), -5., 2., 2., index as f64 * 2.),
2800                index == 15
2801            );
2802        }
2803    }
2804    #[test]
2805    fn cooling_setpoint_hold_resets_on_unavailable_or_invalid_telemetry() {
2806        for (temperature, power) in [
2807            (None, Some(0)),
2808            (Some(-5.), None),
2809            (Some(f64::NAN), Some(0)),
2810            (Some(f64::INFINITY), Some(0)),
2811            (Some(-5.), Some(-1)),
2812            (Some(-5.), Some(101)),
2813        ] {
2814            let mut hold = CoolingHold::default();
2815            for index in 0..=15 {
2816                assert_eq!(
2817                    hold.observe(Some(-5.), Some(0), -5., 2., 2., index as f64 * 2.),
2818                    index == 15
2819                );
2820            }
2821            assert!(!hold.observe(temperature, power, -5., 2., 2., 32.));
2822            for index in 0..=15 {
2823                assert_eq!(
2824                    hold.observe(Some(-5.), Some(0), -5., 2., 2., 34. + index as f64 * 2.),
2825                    index == 15
2826                );
2827            }
2828        }
2829    }
2830    #[tokio::test]
2831    async fn direct_worker_acknowledges_capture_cooling_over_production_framing() {
2832        // Transport primitive only: Session's common acknowledged control queue
2833        // is a separate requirement. Never discover or activate physical cameras.
2834        for mode in ["still", "video"] {
2835            let token = CancellationToken::new();
2836            let mut worker = runtime().spawn(true, log()).await.unwrap();
2837            worker
2838                .call("open", json!({"name":"ZWO ASI585MM Pro"}), 15., &token)
2839                .await
2840                .unwrap();
2841            worker
2842                .call(
2843                    "start",
2844                    json!({"mode":mode,"maxFps":120.0,"width":64,"height":64,
2845                "x":0,"y":0,"bin":1,"microseconds":6_000_000,"dark":false}),
2846                    15.,
2847                    &token,
2848                )
2849                .await
2850                .unwrap();
2851            for (control, value) in [(16, -10), (17, 1), (16, -15)] {
2852                worker
2853                    .call("set", json!({"control":control,"value":value}), 15., &token)
2854                    .await
2855                    .unwrap();
2856                assert_eq!(
2857                    worker
2858                        .call("get", json!({"control":control}), 15., &token)
2859                        .await
2860                        .unwrap()
2861                        .0,
2862                    value
2863                );
2864                assert_eq!(
2865                    worker
2866                        .call("status", Value::Null, 15., &token)
2867                        .await
2868                        .unwrap()
2869                        .0,
2870                    1
2871                );
2872            }
2873            let deadline = Instant::now() + Duration::from_secs(15);
2874            while worker
2875                .call("status", Value::Null, 15., &token)
2876                .await
2877                .unwrap()
2878                .0
2879                == 1
2880            {
2881                assert!(Instant::now() < deadline);
2882                tokio::time::sleep(Duration::from_millis(10)).await;
2883            }
2884            let (metadata, pixels) = worker
2885                .call_image("download", Value::Null, 15., &token, 8192)
2886                .await
2887                .unwrap();
2888            assert_eq!(metadata["mode"], mode);
2889            assert_eq!(
2890                pixels,
2891                (0..4096u16).flat_map(u16::to_le_bytes).collect::<Vec<_>>()
2892            );
2893            worker
2894                .call("close", Value::Null, 15., &token)
2895                .await
2896                .unwrap();
2897            worker.kill().await;
2898        }
2899    }
2900
2901    #[tokio::test]
2902    async fn environment_refreshes_before_sdk_and_direct_exposures_finish() {
2903        for direct in [false, true] {
2904            let token = CancellationToken::new();
2905            let mut sel = selection(direct);
2906            if direct {
2907                sel.name = "ZWO ASI6200MM Pro".into();
2908            }
2909            let mut rt = runtime();
2910            rt.sdk_simulation = Some(json!({"instant":false}));
2911            let mut s = Session::new(sel, rt, log()).unwrap();
2912            s.connect(&token).await.unwrap();
2913            let shared = s.status.clone();
2914            let capture = async {
2915                s.capture(
2916                    Exposure {
2917                        microseconds: 6_000_000,
2918                        ..exposure()
2919                    },
2920                    &token,
2921                )
2922                .await
2923            };
2924            let observe = async {
2925                let deadline = Instant::now() + Duration::from_secs(5);
2926                while shared.lock().unwrap().phase != "Exposing" {
2927                    assert!(Instant::now() < deadline);
2928                    tokio::time::sleep(Duration::from_millis(10)).await;
2929                }
2930                // Make stale frontend values distinguishable from worker telemetry.
2931                {
2932                    let mut state = shared.lock().unwrap();
2933                    state.values.insert(8, 999);
2934                    state.values.insert(15, 99);
2935                }
2936                loop {
2937                    let state = shared.lock().unwrap().clone();
2938                    // The two worker reads update status separately. Wait for
2939                    // both before checking their values, including on slow CI.
2940                    if state.values[&8] != 999 && state.values[&15] != 99 {
2941                        assert_eq!(state.phase, "Exposing");
2942                        assert_eq!(state.values[&8], if direct { 250 } else { -100 });
2943                        assert_eq!(state.values[&15], if direct { 0 } else { 30 });
2944                        break;
2945                    }
2946                    assert!(
2947                        Instant::now() < deadline,
2948                        "Telemetry stayed stale during capture"
2949                    );
2950                    tokio::time::sleep(Duration::from_millis(20)).await;
2951                }
2952            };
2953            let (frame, ()) = tokio::join!(capture, observe);
2954            assert_eq!(frame.unwrap().pixels.len(), 8192);
2955            s.close().await;
2956        }
2957    }
2958    #[tokio::test]
2959    async fn pre_arm_transport_recovery_does_not_replace_a_long_exposure() {
2960        let events = Arc::new(Mutex::new(Vec::<String>::new()));
2961        let sink = events.clone();
2962        let mut sel = selection(false);
2963        sel.recovery.max_retries = 0;
2964        let mut session = Session::new(
2965            sel,
2966            runtime(),
2967            Arc::new(move |_, event, message| {
2968                sink.lock().unwrap().push(
2969                    if event == "session.phase" && message == "Starting exposure" {
2970                        "test.start".into()
2971                    } else {
2972                        event.into()
2973                    },
2974                );
2975            }),
2976        )
2977        .unwrap();
2978        let token = CancellationToken::new();
2979        session.connect(&token).await.unwrap();
2980        session.refresh(&token).await.unwrap();
2981        let old_pid = session.snapshot().process_id;
2982        // A dead transport discovered by pre-arm telemetry is an I/O failure,
2983        // not an exposure. The first subsequent start must still be unique.
2984        session.worker.as_mut().unwrap().kill().await;
2985        events.lock().unwrap().clear();
2986        let frame = session
2987            .capture(
2988                Exposure {
2989                    microseconds: 600_000_000,
2990                    ..exposure()
2991                },
2992                &token,
2993            )
2994            .await
2995            .unwrap();
2996        assert_eq!(frame.metadata["recoveries"], 0);
2997        assert_eq!(session.snapshot().retry.recaptures, 0);
2998        assert_ne!(session.snapshot().process_id, old_pid);
2999        let recorded = events.lock().unwrap().clone();
3000        assert_eq!(
3001            recorded
3002                .iter()
3003                .filter(|e| *e == "connection.pre_exposure_retry")
3004                .count(),
3005            1
3006        );
3007        assert_eq!(
3008            recorded
3009                .iter()
3010                .filter(|e| *e == "connection.opened")
3011                .count(),
3012            1
3013        );
3014        assert_eq!(recorded.iter().filter(|e| *e == "test.start").count(), 1);
3015        drop(recorded);
3016        session.close().await;
3017    }
3018    #[tokio::test]
3019    async fn failed_pre_arm_recovery_is_bounded_and_telemetry_never_reopens() {
3020        let events = Arc::new(Mutex::new(Vec::<String>::new()));
3021        let sink = events.clone();
3022        let mut sel = selection(false);
3023        sel.recovery.max_retries = 20;
3024        sel.recovery.pre_exposure_reconnect_attempts = 3;
3025        let mut session = Session::new(
3026            sel,
3027            runtime(),
3028            Arc::new(move |_, event, message| {
3029                sink.lock().unwrap().push(
3030                    if event == "session.phase" && message == "Starting exposure" {
3031                        "test.start".into()
3032                    } else {
3033                        event.into()
3034                    },
3035                );
3036            }),
3037        )
3038        .unwrap();
3039        let token = CancellationToken::new();
3040        session.connect(&token).await.unwrap();
3041        session.invalidate().await;
3042        session.connection_failed();
3043        let directory = session.runtime.directory.clone();
3044        session.runtime.directory = directory.join("missing-worker-fixture");
3045        events.lock().unwrap().clear();
3046        assert!(session.capture(exposure(), &token).await.is_err());
3047        assert_eq!(session.snapshot().retry.recaptures, 0);
3048        assert!(session.worker.is_none());
3049        assert!(session.reconnect_not_before.is_some());
3050        let failures = session.connection_failures;
3051        let backoff = session.reconnect_not_before;
3052        for _ in 0..5 {
3053            assert!(session.refresh(&token).await.is_err());
3054        }
3055        assert_eq!(session.connection_failures, failures);
3056        assert_eq!(session.reconnect_not_before, backoff);
3057        let recorded = events.lock().unwrap().clone();
3058        assert_eq!(
3059            recorded
3060                .iter()
3061                .filter(|e| *e == "connection.pre_exposure_retry")
3062                .count(),
3063            2
3064        );
3065        assert!(
3066            !recorded
3067                .iter()
3068                .any(|e| e == "capture.retry" || e == "connection.opened" || e == "test.start")
3069        );
3070        drop(recorded);
3071        // A queued imaging change remains intent until the next successful
3072        // preparation; telemetry neither sends nor discards it during outage.
3073        Session::queue_control(&session.status, 0, 234).unwrap();
3074        session.runtime.directory = directory;
3075        let frame = session
3076            .capture(
3077                Exposure {
3078                    microseconds: 600_000_000,
3079                    ..exposure()
3080                },
3081                &token,
3082            )
3083            .await
3084            .unwrap();
3085        assert_eq!(frame.metadata["recoveries"], 0);
3086        assert_eq!(frame.metadata["controls"]["0"], 234);
3087        assert_eq!(session.connection_failures, 0);
3088        assert!(session.reconnect_not_before.is_none());
3089        session.close().await;
3090    }
3091    #[tokio::test]
3092    async fn configured_usb_reset_can_restore_pre_arm_connection_for_long_exposure() {
3093        for threshold in [0, 2] {
3094            let mut sel = selection(false);
3095            sel.recovery.max_retries = 0;
3096            sel.recovery.usb_reset_after_failures = threshold;
3097            let mut session = Session::new(sel, runtime(), log()).unwrap();
3098            let token = CancellationToken::new();
3099            session.connect(&token).await.unwrap();
3100            session.invalidate().await;
3101            session.connection_failed();
3102            session.connection_failed();
3103            let frame = session
3104                .capture(
3105                    Exposure {
3106                        microseconds: 600_000_000,
3107                        ..exposure()
3108                    },
3109                    &token,
3110                )
3111                .await
3112                .unwrap();
3113            assert_eq!(
3114                frame.metadata["usbResets"],
3115                if threshold == 0 { 0 } else { 1 }
3116            );
3117            assert_eq!(frame.metadata["recoveries"], 0);
3118            session.close().await;
3119        }
3120    }
3121    #[tokio::test]
3122    async fn usb_peripheral_confirmation_fences_real_session_start_and_future_requests() {
3123        for outcome in ["ready", "failed", "cancelled"] {
3124            let events = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
3125            let sink = events.clone();
3126            let mut sel = selection(false);
3127            sel.recovery.max_retries = 0;
3128            sel.recovery.usb_reset_after_failures = 1;
3129            let mut session = Session::new(
3130                sel,
3131                runtime(),
3132                Arc::new(move |_, event, message| {
3133                    sink.lock().unwrap().push((event.into(), message.into()));
3134                }),
3135            )
3136            .unwrap();
3137            session.wait_for_usb_peripherals(true).unwrap();
3138            let token = CancellationToken::new();
3139            session.connect(&token).await.unwrap();
3140            session.invalidate().await;
3141            session.connection_failed();
3142            let handle = session.usb_peripheral_recovery();
3143            let status = session.status.clone();
3144            let capture_token = token.clone();
3145            let capture = tokio::spawn(async move {
3146                let result = session
3147                    .capture(
3148                        Exposure {
3149                            microseconds: 600_000_000,
3150                            ..exposure()
3151                        },
3152                        &capture_token,
3153                    )
3154                    .await;
3155                (session, result)
3156            });
3157            let id = tokio::time::timeout(Duration::from_secs(10), async {
3158                loop {
3159                    if let Some(id) = status.lock().unwrap().usb_peripheral_recovery_id {
3160                        break id;
3161                    }
3162                    tokio::time::sleep(Duration::from_millis(10)).await;
3163                }
3164            })
3165            .await
3166            .unwrap();
3167            assert_eq!(status.lock().unwrap().phase, "Waiting for NINA peripherals");
3168            assert!(!capture.is_finished());
3169            {
3170                let recorded = events.lock().unwrap();
3171                assert!(
3172                    !recorded
3173                        .iter()
3174                        .any(|(event, message)| event == "session.phase"
3175                            && message == "Starting exposure")
3176                );
3177                for event in ["usb.reset", "usb.returned"] {
3178                    let (_, message) = recorded.iter().find(|(e, _)| e == event).unwrap();
3179                    let record = crate::worker::diagnostic_record(message).unwrap();
3180                    assert_eq!(record["details"]["usbPeripheralRecoveryId"], id);
3181                    assert_eq!(record["pid"], std::process::id());
3182                    assert!(
3183                        record["timestampUnixUs"]
3184                            .as_i64()
3185                            .is_some_and(|value| value > 0)
3186                    );
3187                }
3188            }
3189            if outcome == "cancelled" {
3190                token.cancel();
3191            } else {
3192                handle
3193                    .acknowledge(usb_peripherals::Acknowledgement {
3194                        id,
3195                        success: outcome == "ready",
3196                        error: (outcome == "failed").then(|| "Filter wheel changed".into()),
3197                    })
3198                    .unwrap();
3199            }
3200            let (mut session, result) = capture.await.unwrap();
3201            assert_eq!(result.is_ok(), outcome == "ready");
3202            assert!(session.snapshot().usb_peripheral_recovery_id.is_none());
3203            assert!(
3204                handle
3205                    .acknowledge(usb_peripherals::Acknowledgement {
3206                        id,
3207                        success: true,
3208                        error: None
3209                    })
3210                    .is_err()
3211            );
3212            if outcome != "ready" {
3213                let next = session
3214                    .capture(exposure(), &CancellationToken::new())
3215                    .await
3216                    .err()
3217                    .unwrap();
3218                assert!(next.to_string().contains("disconnect and reconnect"));
3219            }
3220            assert_eq!(
3221                events
3222                    .lock()
3223                    .unwrap()
3224                    .iter()
3225                    .filter(|(event, message)| event == "session.phase"
3226                        && message == "Starting exposure")
3227                    .count(),
3228                usize::from(outcome == "ready")
3229            );
3230            session.close().await;
3231        }
3232    }
3233    #[tokio::test]
3234    async fn usb_escalation_is_opt_in_bounded_and_obeys_exposure_limits() {
3235        for (threshold, retries, seconds, resets) in [
3236            (0, 3, 0.01, 0),
3237            (2, 3, 0.01, 1),
3238            (1, 0, 0.01, 0),
3239            (1, 3, 31., 0),
3240            (4, 3, 0.01, 0),
3241        ] {
3242            let events = Arc::new(Mutex::new(Vec::<String>::new()));
3243            let sink = events.clone();
3244            let mut sel = selection(false);
3245            sel.recovery.usb_reset_after_failures = threshold;
3246            sel.recovery.max_retries = retries;
3247            sel.recovery.ready_frame_download_retries = 0;
3248            let mut rt = runtime();
3249            rt.sdk_simulation = Some(json!({"instant":true,"fault":"download"}));
3250            let mut session = Session::new(
3251                sel,
3252                rt,
3253                Arc::new(move |_, event, _| sink.lock().unwrap().push(event.into())),
3254            )
3255            .unwrap();
3256            let token = CancellationToken::new();
3257            session.connect(&token).await.unwrap();
3258            assert!(
3259                session
3260                    .capture(
3261                        Exposure {
3262                            microseconds: (seconds * 1e6) as u64,
3263                            ..exposure()
3264                        },
3265                        &token
3266                    )
3267                    .await
3268                    .is_err()
3269            );
3270            assert_eq!(
3271                events
3272                    .lock()
3273                    .unwrap()
3274                    .iter()
3275                    .filter(|e| e.as_str() == "usb.reset")
3276                    .count(),
3277                resets
3278            );
3279            session.close().await;
3280        }
3281    }
3282    #[tokio::test]
3283    async fn usb_recovery_restores_controls_and_cancellation_never_resets() {
3284        let token = CancellationToken::new();
3285        let mut sel = selection(false);
3286        sel.recovery.usb_reset_after_failures = 1;
3287        sel.recovery.ready_frame_download_retries = 0;
3288        let mut session = Session::new(sel, runtime(), log()).unwrap();
3289        session.connect(&token).await.unwrap();
3290        Session::queue_control(&session.status, 0, 230).unwrap();
3291        session
3292            .call("fault", json!({"kind":"download"}), None, &token)
3293            .await
3294            .unwrap();
3295        let frame = session.capture(exposure(), &token).await.unwrap();
3296        assert_eq!(frame.metadata["usbResets"], 1);
3297        assert_eq!(frame.metadata["recoveries"], 1);
3298        assert_eq!(frame.metadata["controls"]["0"], 230);
3299        let cancelled = CancellationToken::new();
3300        cancelled.cancel();
3301        assert!(
3302            session
3303                .capture(exposure(), &cancelled)
3304                .await
3305                .err()
3306                .unwrap()
3307                .is::<Failure>()
3308        );
3309        session.close().await;
3310    }
3311    #[tokio::test]
3312    async fn delayed_transfer_diagnostic_cannot_replace_final_attempt_failure() {
3313        for (mode, replacements) in ["circular", "verbose", "off"]
3314            .into_iter()
3315            .flat_map(|mode| [0, 1].map(|replacements| (mode, replacements)))
3316        {
3317            let callback = Arc::new(std::sync::OnceLock::<Diagnostic>::new());
3318            let delayed = Arc::downgrade(&callback);
3319            let delivered = Arc::new(std::sync::atomic::AtomicBool::new(false));
3320            let observed = delivered.clone();
3321            let log: Diagnostic = Arc::new(move |_, event, _| {
3322                if event == "capture.failed"
3323                    && !observed.swap(true, std::sync::atomic::Ordering::SeqCst)
3324                {
3325                    // Deliver worker stderr after the framed failure response,
3326                    // before retirement completes, without relying on a race.
3327                    let callback = delayed.upgrade().unwrap();
3328                    callback.get().unwrap()(
3329                        "warning",
3330                        "transfer.exhausted",
3331                        "REGAIN_DIAGNOSTIC {\"version\":1,\"event\":\"transfer.exhausted\",\"message\":\"read retry limit reached\"}",
3332                    );
3333                }
3334            });
3335            let mut sel = selection(true);
3336            sel.recovery.max_retries = replacements;
3337            sel.recovery.transfer_log_mode = mode.into();
3338            let mut session = Session::new(sel, runtime(), log).unwrap();
3339            assert!(callback.set(session.log.clone()).is_ok());
3340            let token = CancellationToken::new();
3341            session.connect(&token).await.unwrap();
3342            session
3343                .call("simulate-read-failures", json!({"count":3}), None, &token)
3344                .await
3345                .unwrap();
3346            let result = session.capture(exposure(), &token).await;
3347            assert_eq!(result.is_ok(), replacements == 1);
3348            assert!(delivered.load(std::sync::atomic::Ordering::SeqCst));
3349            let status = session.snapshot();
3350            assert_eq!(status.retry.recaptures, replacements);
3351            assert_eq!(status.retry.usb_reads, 2, "logging mode: {mode}");
3352            assert!(status.retry.last_failure.unwrap().contains("retry budget"));
3353            session.close().await;
3354        }
3355    }
3356    #[tokio::test]
3357    async fn sdk_ready_frame_read_retry_does_not_replace_exposure() {
3358        let token = CancellationToken::new();
3359        let mut s = Session::new(selection(false), runtime(), log()).unwrap();
3360        s.connect(&token).await.unwrap();
3361        s.call("fault", json!({"kind":"download"}), None, &token)
3362            .await
3363            .unwrap();
3364        let frame = s.capture(exposure(), &token).await.unwrap();
3365        assert_eq!(frame.metadata["recoveries"], 0);
3366        assert_eq!(frame.metadata["downloadRetries"], 1);
3367        assert_eq!(frame.pixels.len(), 8192);
3368        let status = s.snapshot();
3369        assert_eq!(status.retry.downloads, 1);
3370        assert_eq!(status.retry.recaptures, 0);
3371        assert!(status.recovery_info().contains("state: Idle; retries: 1"));
3372        assert!(status.retry.last_failure.as_deref().unwrap().contains("11"));
3373        s.capture(exposure(), &token).await.unwrap();
3374        assert_eq!(s.snapshot().retry.downloads, 0);
3375        assert_eq!(s.snapshot().retry.last_failure, status.retry.last_failure);
3376        s.close().await;
3377    }
3378    #[tokio::test]
3379    async fn sdk_replacement_restores_controls_and_respects_long_exposure_limit() {
3380        for (seconds, allowed) in [(30., true), (30.000001, false), (1200., false)] {
3381            let token = CancellationToken::new();
3382            let mut sel = selection(false);
3383            sel.recovery.ready_frame_download_retries = 0;
3384            let mut s = Session::new(sel, runtime(), log()).unwrap();
3385            s.connect(&token).await.unwrap();
3386            Session::queue_control(&s.status, 0, 250).unwrap();
3387            Session::queue_control(&s.status, 16, -10).unwrap();
3388            s.call("fault", json!({"kind":"download"}), None, &token)
3389                .await
3390                .unwrap();
3391            let result = s
3392                .capture(
3393                    Exposure {
3394                        microseconds: (seconds * 1e6) as u64,
3395                        ..exposure()
3396                    },
3397                    &token,
3398                )
3399                .await;
3400            assert_eq!(result.is_ok(), allowed);
3401            if let Ok(frame) = result {
3402                assert_eq!(frame.metadata["recoveries"], 1);
3403                assert_eq!(frame.metadata["controls"]["0"], 250);
3404                assert_eq!(s.snapshot().retry.recaptures, 1);
3405                assert!(
3406                    s.snapshot()
3407                        .recovery_info()
3408                        .contains("state: Idle; retries: 1")
3409                );
3410            } else {
3411                assert_eq!(s.snapshot().retry.recaptures, 0);
3412                assert!(
3413                    s.snapshot()
3414                        .recovery_info()
3415                        .contains("state: Error; retries: 0")
3416                );
3417            }
3418            s.close().await;
3419        }
3420    }
3421    #[tokio::test]
3422    async fn direct_retained_reads_and_abort_share_the_recovery_session() {
3423        let token = CancellationToken::new();
3424        let mut s = Session::new(selection(true), runtime(), log()).unwrap();
3425        s.connect(&token).await.unwrap();
3426        s.call("simulate-read-failures", json!({"count":2}), None, &token)
3427            .await
3428            .unwrap();
3429        let frame = s.capture(exposure(), &token).await.unwrap();
3430        assert_eq!(frame.metadata["readRecoveries"], 2);
3431        assert_eq!(frame.metadata["recoveries"], 0);
3432        assert_eq!(s.snapshot().retry.usb_reads, 2);
3433        assert!(
3434            s.snapshot()
3435                .recovery_info()
3436                .contains("state: Idle; retries: 2")
3437        );
3438        let abort = CancellationToken::new();
3439        let cancel = abort.clone();
3440        tokio::spawn(async move {
3441            tokio::time::sleep(Duration::from_millis(20)).await;
3442            cancel.cancel();
3443        });
3444        assert!(
3445            s.capture(
3446                Exposure {
3447                    microseconds: 2000000,
3448                    ..exposure()
3449                },
3450                &abort
3451            )
3452            .await
3453            .is_err()
3454        );
3455        assert_eq!(
3456            s.capture(exposure(), &token).await.unwrap().pixels.len(),
3457            8192
3458        );
3459        s.close().await;
3460    }
3461    #[tokio::test]
3462    async fn intentional_abort_keeps_worker_cooling_and_does_not_retry() {
3463        for direct in [false, true] {
3464            let token = CancellationToken::new();
3465            let mut sel = selection(direct);
3466            if direct {
3467                sel.name = "ZWO ASI6200MM Pro".into();
3468            }
3469            let mut rt = runtime();
3470            rt.sdk_simulation = Some(json!({"instant":false,"temperature":250,"coolerPower":70}));
3471            let events = Arc::new(Mutex::new(Vec::new()));
3472            let recorded = events.clone();
3473            let mut s = Session::new(
3474                sel,
3475                rt,
3476                Arc::new(move |_, event, _| recorded.lock().unwrap().push(event.to_owned())),
3477            )
3478            .unwrap();
3479            s.connect(&token).await.unwrap();
3480            Session::queue_control(&s.status, 16, 25).unwrap();
3481            Session::queue_control(&s.status, 17, 1).unwrap();
3482            s.refresh(&token).await.unwrap();
3483            if direct {
3484                s.call(
3485                    "resume-cooling",
3486                    json!({"power":70,"temperature":25.,"previousTarget":25}),
3487                    None,
3488                    &token,
3489                )
3490                .await
3491                .unwrap();
3492                s.refresh(&token).await.unwrap();
3493            }
3494            let pid = s.snapshot().process_id;
3495            let abort = CancellationToken::new();
3496            let cancel = abort.clone();
3497            let status = s.status.clone();
3498            let cancellation = tokio::spawn(async move {
3499                exposing(&status).await;
3500                cancel.cancel();
3501            });
3502            let result = tokio::time::timeout(
3503                Duration::from_secs(5),
3504                s.capture(
3505                    Exposure {
3506                        microseconds: 600_000_000,
3507                        ..exposure()
3508                    },
3509                    &abort,
3510                ),
3511            )
3512            .await
3513            .unwrap();
3514            cancellation.await.unwrap();
3515            assert!(matches!(
3516                result.err().unwrap().downcast_ref::<Failure>(),
3517                Some(Failure::Cancelled)
3518            ));
3519            assert_eq!(s.snapshot().process_id, pid);
3520            assert!(s.snapshot().control_connection_available);
3521            assert_eq!(s.snapshot().values[&17], 1);
3522            assert_eq!(s.snapshot().values[&15], 70);
3523            assert!(s.snapshot().retry.last_failure.is_none());
3524            assert_eq!(
3525                s.capture(exposure(), &token).await.unwrap().metadata["recoveries"],
3526                0
3527            );
3528            assert_eq!(s.snapshot().process_id, pid);
3529            assert!(
3530                !events
3531                    .lock()
3532                    .unwrap()
3533                    .iter()
3534                    .any(|e| e == "capture.failed" || e == "capture.retry")
3535            );
3536            s.close().await;
3537        }
3538    }
3539    #[tokio::test]
3540    async fn thermal_cleanup_does_not_restore_white_balance_or_reactivate_controls() {
3541        let token = CancellationToken::new();
3542        let mut s = Session::new(selection(false), runtime(), log()).unwrap();
3543        s.connect(&token).await.unwrap();
3544        s.set_white_balance(
3545            crate::white_balance::Settings {
3546                mode: crate::white_balance::Mode::Manual,
3547                ..Default::default()
3548            },
3549            &token,
3550        )
3551        .await
3552        .unwrap();
3553        s.invalidate().await;
3554        s.status.lock().unwrap().connected = false;
3555        s.open_worker(&token, OpenPurpose::ThermalShutdown)
3556            .await
3557            .unwrap();
3558        assert!(!s.snapshot().connected);
3559        assert!(!s.snapshot().control_connection_available);
3560        assert_eq!(s.snapshot().phase, "Closing camera");
3561        assert!(s.cooling().submit(17, 1, Duration::from_secs(1)).is_err());
3562        let actual = s
3563            .call("white-balance", Value::Null, None, &token)
3564            .await
3565            .unwrap()
3566            .0;
3567        assert_eq!(actual["managed"], false);
3568        s.close().await;
3569    }
3570    #[tokio::test]
3571    async fn failed_abort_retires_worker_before_another_capture() {
3572        let token = CancellationToken::new();
3573        let mut s = Session::new(selection(true), runtime(), log()).unwrap();
3574        s.connect(&token).await.unwrap();
3575        let pid = s.snapshot().process_id;
3576        s.call("simulation", json!({"cleanupFailure":true}), None, &token)
3577            .await
3578            .unwrap();
3579        let abort = CancellationToken::new();
3580        let cancel = abort.clone();
3581        let status = s.status.clone();
3582        let cancellation = tokio::spawn(async move {
3583            exposing(&status).await;
3584            cancel.cancel();
3585        });
3586        let result = s
3587            .capture(
3588                Exposure {
3589                    microseconds: 600_000_000,
3590                    ..exposure()
3591                },
3592                &abort,
3593            )
3594            .await;
3595        cancellation.await.unwrap();
3596        assert!(matches!(
3597            result.err().unwrap().downcast_ref::<Failure>(),
3598            Some(Failure::Cancelled)
3599        ));
3600        assert!(!s.snapshot().control_connection_available);
3601        assert_eq!(s.snapshot().process_id, None);
3602        assert!(
3603            s.snapshot()
3604                .retry
3605                .last_failure
3606                .unwrap()
3607                .contains("Stop was not acknowledged")
3608        );
3609        s.capture(exposure(), &token).await.unwrap();
3610        assert_ne!(s.snapshot().process_id, pid);
3611        s.close().await;
3612    }
3613    #[tokio::test]
3614    async fn direct_fallback_revalidates_same_identity_and_uses_one_budget() {
3615        let token = CancellationToken::new();
3616        let mut sel = selection(true);
3617        sel.sdk_fallback = true;
3618        let mut runtime = runtime();
3619        runtime.sdk_simulation = Some(
3620            json!({"name":"ZWO ASI676MC","width":3552,"height":3552,"bins":[1],"serial":"direct-simulator","cooled":false,"instant":true}),
3621        );
3622        let mut s = Session::new(sel, runtime, log()).unwrap();
3623        s.connect(&token).await.unwrap();
3624        s.call("simulate-read-failures", json!({"count":3}), None, &token)
3625            .await
3626            .unwrap();
3627        let frame = s.capture(exposure(), &token).await.unwrap();
3628        assert_eq!(frame.metadata["backend"], "sdk");
3629        assert_eq!(frame.metadata["sdkFallback"], true);
3630        assert_eq!(frame.metadata["recoveries"], 1);
3631        assert_eq!(frame.metadata["retainedReadRetries"], 2);
3632        assert_eq!(frame.metadata["readRecoveries"].as_u64().unwrap_or(0), 0);
3633        s.close().await;
3634    }
3635    #[test]
3636    fn invalid_limits_are_rejected() {
3637        assert!(
3638            RecoveryOptions {
3639                maximum_retry_exposure_seconds: f64::NAN,
3640                ..RecoveryOptions::default()
3641            }
3642            .validate()
3643            .is_err()
3644        );
3645        assert!(
3646            RecoveryOptions {
3647                direct_read_retries: 6,
3648                ..RecoveryOptions::default()
3649            }
3650            .validate()
3651            .is_err()
3652        );
3653    }
3654}