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