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