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