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}
44pub 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 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 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 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(()), }
239 }
240 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 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 self.invalidate().await;
418 Err(Failure::Cancelled.into())
419 }
420 Err(error) => Err(error.into()),
421 Ok(actual) => Ok(actual),
422 }
423 }
424 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 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 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 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 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 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 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 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 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 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 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 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 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 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)] 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 let exchange_token = CancellationToken::new();
1361 if token.is_cancelled() {
1362 return Err(Failure::Cancelled.into());
1363 }
1364 *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 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 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 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 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 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 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 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 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 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 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); 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 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 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 {
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 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 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 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 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}