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