1use std::{future::Future, sync::Arc, time::Duration};
10
11use hidpp::{
12 channel::HidppChannel,
13 device::Device,
14 feature::{
15 CreatableFeature,
16 change_host::ChangeHostFeature,
17 hosts_info::{HostIndex, HostSlotStatus, HostsInfoFeature},
18 },
19 protocol::v20,
20};
21use thiserror::Error;
22use tokio::{
23 sync::{mpsc, oneshot},
24 time::timeout,
25};
26use tracing::{debug, info};
27
28use crate::{
29 ChannelPool, DeviceRoute,
30 backend::BackendError,
31 reprog_controls::{self, ReprogControlsV4},
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum HostSwitchStopReason {
37 Graceful,
39 DeviceLost,
41}
42
43const HOST_CONTROL_IDS: [(reprog_controls::ControlId, u8); 3] = [
44 (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_1, 0),
45 (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_2, 1),
46 (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_3, 2),
47];
48const HOST_TASK_IDS: [(reprog_controls::TaskId, u8); 3] = [
49 (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_1, 0),
50 (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_2, 1),
51 (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_3, 2),
52];
53const HIDPP_OPERATION_TIMEOUT: Duration = Duration::from_secs(5);
54
55#[derive(Clone, Copy)]
56enum ReportingMode {
57 Diverted,
58 Analytics,
59}
60
61#[derive(Clone, Copy)]
62struct ArmedControl {
63 cid: u16,
64 host: u8,
65 mode: ReportingMode,
66 original: reprog_controls::CidReporting,
67}
68
69#[derive(Debug, Error)]
71pub enum HostSwitchError {
72 #[error("HID transport error")]
74 Hid(#[from] BackendError),
75 #[error("configured keyboard is not connected")]
77 KeyboardNotFound,
78 #[error("configured linked device is not connected")]
80 TargetNotFound,
81 #[error("HID++ protocol error: {0}")]
83 Hidpp(String),
84 #[error("HID++ operation timed out while {operation}")]
86 TimedOut {
87 operation: &'static str,
89 },
90 #[error("keyboard exposes no reportable host switch controls")]
92 UnsupportedKeyboard,
93 #[error("host {host} is not paired on this device")]
96 HostSlotEmpty {
97 host: u8,
99 },
100}
101
102pub async fn run_host_switch_session(
105 keyboard: DeviceRoute,
106 shutdown: oneshot::Receiver<HostSwitchStopReason>,
107 channel_pool: ChannelPool,
108) -> Result<Option<u8>, HostSwitchError> {
109 let channel = open_channel(&channel_pool, &keyboard, "opening keyboard channel")
110 .await?
111 .ok_or(HostSwitchError::KeyboardNotFound)?;
112 let keyboard_index = keyboard.device_index();
113 let device = timed_hidpp(
114 "opening keyboard device",
115 Device::new(Arc::clone(&channel), keyboard_index),
116 )
117 .await?;
118 let feature = timed_hidpp(
119 "locating host controls",
120 device.root().get_feature(reprog_controls::FEATURE_ID),
121 )
122 .await?
123 .ok_or(HostSwitchError::UnsupportedKeyboard)?;
124 let controls = ReprogControlsV4::new(Arc::clone(&channel), keyboard_index, feature.index);
125
126 let armed = arm_host_controls(&controls).await?;
127 if armed.is_empty() {
128 return Err(HostSwitchError::UnsupportedKeyboard);
129 }
130
131 let (press_tx, mut press_rx) = mpsc::unbounded_channel();
132 let feature_index = controls.feature_index();
133 let event_controls = armed.clone();
134 let listener = channel.add_msg_listener_guarded(move |raw, matched| {
135 if matched {
136 return;
137 }
138 let message = v20::Message::from(raw);
139 let Some(event) =
140 reprog_controls::decode_full_event(&message, keyboard_index, feature_index)
141 else {
142 return;
143 };
144 if let Some(host) = event_host(&event_controls, event) {
145 let _ = press_tx.send(host);
146 }
147 });
148
149 info!(
150 route = %keyboard,
151 controls = armed.len(),
152 "host switch link active"
153 );
154 let outcome = tokio::select! {
155 reason = shutdown => {
156 let reason = reason.unwrap_or(HostSwitchStopReason::DeviceLost);
157 (None, reason == HostSwitchStopReason::Graceful)
158 },
159 Some(host) = press_rx.recv() => (Some(host), true),
160 };
161
162 drop(listener);
163 if outcome.1 {
164 restore_host_controls(&controls, armed).await;
165 }
166 Ok(outcome.0)
167}
168
169pub async fn switch_linked_hosts(
173 keyboard: &DeviceRoute,
174 targets: &[DeviceRoute],
175 host: u8,
176 channel_pool: &ChannelPool,
177) -> Result<bool, HostSwitchError> {
178 let channel = open_channel(channel_pool, keyboard, "opening keyboard channel")
179 .await?
180 .ok_or(HostSwitchError::KeyboardNotFound)?;
181 let keyboard_change = prepare_host_change_on(&channel, keyboard.device_index(), host).await?;
188 for target in targets {
189 match prepare_host_change(target, host, keyboard, &channel, channel_pool).await {
190 Ok(change) => {
191 if let Err(error) = apply_host_change(change).await {
192 debug!(%error, route = %target, host, "linked device host switch failed");
193 }
194 }
195 Err(error) => {
196 debug!(%error, route = %target, host, "linked device host switch preparation failed");
197 }
198 }
199 }
200 let changed = apply_host_change(keyboard_change).await?;
201 if changed {
202 debug!(host, route = %keyboard, "keyboard host switched");
203 }
204 Ok(changed)
205}
206
207async fn arm_host_controls(
208 controls: &ReprogControlsV4,
209) -> Result<Vec<ArmedControl>, HostSwitchError> {
210 let mut armed = Vec::new();
211 if let Err(error) = arm_host_controls_inner(controls, &mut armed).await {
212 restore_host_controls(controls, armed).await;
213 return Err(error);
214 }
215 Ok(armed)
216}
217
218async fn arm_host_controls_inner(
219 controls: &ReprogControlsV4,
220 armed: &mut Vec<ArmedControl>,
221) -> Result<(), HostSwitchError> {
222 let count = timed_hidpp("reading host control count", controls.get_count()).await?;
223 for index in 0..count {
224 let info = timed_hidpp(
225 "reading host control information",
226 controls.get_ctrl_id_info(index),
227 )
228 .await?;
229 let Some(host) = host_channel(info) else {
230 continue;
231 };
232 debug!(
233 cid = format_args!("{:#06x}", info.cid),
234 task_id = format_args!("{:#06x}", info.task_id),
235 host,
236 divertable = info.is_divertable(),
237 analytics = info.supports_analytics_events(),
238 "host switch control discovered"
239 );
240 let mode = if info.is_divertable() {
241 Some(ReportingMode::Diverted)
242 } else if info.supports_analytics_events() {
243 Some(ReportingMode::Analytics)
244 } else {
245 None
246 };
247 if let Some(mode) = mode {
248 let original = timed_hidpp(
249 "reading host control reporting",
250 controls.get_cid_reporting(info.cid),
251 )
252 .await?;
253 armed.push(ArmedControl {
257 cid: info.cid,
258 host,
259 mode,
260 original,
261 });
262 match mode {
263 ReportingMode::Diverted => {
264 timed_hidpp(
265 "diverting host control",
266 controls.set_cid_reporting(info.cid, true, false),
267 )
268 .await?;
269 }
270 ReportingMode::Analytics => {
271 timed_hidpp(
272 "enabling host control analytics",
273 controls.set_cid_reporting_full(
274 info.cid,
275 reprog_controls::CidReportingChange {
276 analytics_key_events: Some(true),
277 ..reprog_controls::CidReportingChange::default()
278 },
279 ),
280 )
281 .await?;
282 }
283 }
284 }
285 }
286 Ok(())
287}
288
289async fn restore_host_controls(controls: &ReprogControlsV4, armed: Vec<ArmedControl>) {
290 for control in armed {
291 let mut restored = restore_host_control(controls, control).await;
292 if restored.is_err() {
293 restored = restore_host_control(controls, control).await;
294 }
295 if let Err(error) = restored {
296 debug!(
297 ?error,
298 cid = control.cid,
299 "could not restore host switch control"
300 );
301 }
302 }
303}
304
305async fn restore_host_control(
306 controls: &ReprogControlsV4,
307 control: ArmedControl,
308) -> Result<(), HostSwitchError> {
309 timed_hidpp(
310 "restoring host control reporting",
311 controls.set_cid_reporting_full(control.cid, restoration_change(control)),
312 )
313 .await
314 .map(|_echo| ())
315}
316
317fn restoration_change(control: ArmedControl) -> reprog_controls::CidReportingChange {
318 match control.mode {
319 ReportingMode::Diverted => reprog_controls::CidReportingChange {
320 diverted: Some(control.original.diverted),
321 raw_xy: Some(control.original.raw_xy),
322 ..reprog_controls::CidReportingChange::default()
323 },
324 ReportingMode::Analytics => reprog_controls::CidReportingChange {
325 analytics_key_events: Some(control.original.analytics_key_events),
326 ..reprog_controls::CidReportingChange::default()
327 },
328 }
329}
330
331struct PreparedHostChange {
332 feature: Arc<ChangeHostFeature>,
333 device_index: u8,
334 host: u8,
335 required: bool,
336}
337
338async fn prepare_host_change(
339 target: &DeviceRoute,
340 host: u8,
341 keyboard: &DeviceRoute,
342 keyboard_channel: &Arc<HidppChannel>,
343 channel_pool: &ChannelPool,
344) -> Result<PreparedHostChange, HostSwitchError> {
345 if shares_channel(target, keyboard) {
346 prepare_host_change_on(keyboard_channel, target.device_index(), host).await
347 } else {
348 let channel = open_channel(channel_pool, target, "opening linked device channel")
349 .await?
350 .ok_or(HostSwitchError::TargetNotFound)?;
351 prepare_host_change_on(&channel, target.device_index(), host).await
352 }
353}
354
355async fn prepare_host_change_on(
356 channel: &Arc<HidppChannel>,
357 device_index: u8,
358 host: u8,
359) -> Result<PreparedHostChange, HostSwitchError> {
360 let mut device = timed_hidpp(
361 "opening host-change device",
362 Device::new(Arc::clone(channel), device_index),
363 )
364 .await?;
365 let info = timed_hidpp(
366 "locating host-change feature",
367 device.root().get_feature(ChangeHostFeature::ID),
368 )
369 .await?
370 .ok_or_else(|| HostSwitchError::Hidpp("ChangeHost is unsupported".into()))?;
371 let change_host = device.add_feature::<ChangeHostFeature>(info.index);
372 let state = timed_hidpp("reading current host", change_host.get_host_info()).await?;
373 let required = host_change_required(state.current_host, state.host_count, host)?;
374 if required && host_slot_is_empty(&mut device, host).await {
375 return Err(HostSwitchError::HostSlotEmpty { host });
376 }
377 Ok(PreparedHostChange {
378 feature: change_host,
379 device_index,
380 host,
381 required,
382 })
383}
384
385async fn apply_host_change(change: PreparedHostChange) -> Result<bool, HostSwitchError> {
386 if !change.required {
387 let PreparedHostChange {
388 device_index, host, ..
389 } = change;
390 debug!(device_index, host, "device already uses requested host");
391 return Ok(false);
392 }
393 timed_hidpp(
394 "writing current host",
395 change.feature.set_current_host(change.host),
396 )
397 .await?;
398 Ok(true)
399}
400
401async fn open_channel(
402 channel_pool: &ChannelPool,
403 route: &DeviceRoute,
404 operation: &'static str,
405) -> Result<Option<Arc<HidppChannel>>, HostSwitchError> {
406 timeout(HIDPP_OPERATION_TIMEOUT, channel_pool.open(route))
407 .await
408 .map_err(|_| HostSwitchError::TimedOut { operation })?
409 .map_err(HostSwitchError::Hid)
410}
411
412async fn timed_hidpp<T, E>(
413 operation: &'static str,
414 future: impl Future<Output = Result<T, E>>,
415) -> Result<T, HostSwitchError>
416where
417 E: std::fmt::Debug,
418{
419 timeout(HIDPP_OPERATION_TIMEOUT, future)
420 .await
421 .map_err(|_| HostSwitchError::TimedOut { operation })?
422 .map_err(|error| hidpp_error(operation, error))
423}
424
425async fn host_slot_is_empty(device: &mut Device, host: u8) -> bool {
441 let feature = timed_hidpp(
442 "locating hosts-info feature",
443 device.root().get_feature(HostsInfoFeature::ID),
444 )
445 .await;
446 let index = match feature {
447 Ok(Some(info)) => info.index,
448 Ok(None) => return false,
449 Err(error) => {
450 debug!(host, %error, "hosts-info lookup failed; treating the slot as usable");
451 return false;
452 }
453 };
454 let hosts_info = device.add_feature::<HostsInfoFeature>(index);
455 match timed_hidpp(
456 "reading host slot status",
457 hosts_info.get_host_info(HostIndex::Slot(host)),
458 )
459 .await
460 {
461 Ok(slot) => slot.status == HostSlotStatus::Empty,
462 Err(error) => {
463 debug!(host, %error, "host slot status is unreadable; treating the slot as usable");
464 false
465 }
466 }
467}
468
469fn host_change_required(
470 current_host: u8,
471 host_count: u8,
472 requested_host: u8,
473) -> Result<bool, HostSwitchError> {
474 if requested_host >= host_count {
475 return Err(HostSwitchError::Hidpp(format!(
476 "host {requested_host} is outside device host count {host_count}"
477 )));
478 }
479 Ok(current_host != requested_host)
480}
481
482fn shares_channel(left: &DeviceRoute, right: &DeviceRoute) -> bool {
483 left.shares_transport(right)
484}
485
486fn hidpp_error(operation: &'static str, error: impl std::fmt::Debug) -> HostSwitchError {
487 HostSwitchError::Hidpp(format!("{operation}: {error:?}"))
488}
489
490fn host_channel(info: reprog_controls::CtrlIdInfo) -> Option<u8> {
491 HOST_CONTROL_IDS
492 .iter()
493 .find_map(|(cid, host)| (info.cid == cid.0).then_some(*host))
494 .or_else(|| {
495 HOST_TASK_IDS
496 .iter()
497 .find_map(|(task, host)| (info.task_id == task.0).then_some(*host))
498 })
499}
500
501fn event_host(
502 controls: &[ArmedControl],
503 event: reprog_controls::ReprogControlsEvent,
504) -> Option<u8> {
505 match event {
506 reprog_controls::ReprogControlsEvent::DivertedButtons(cids) => controls
507 .iter()
508 .find_map(|control| cids.contains(&control.cid.into()).then_some(control.host)),
509 reprog_controls::ReprogControlsEvent::AnalyticsKeyEvents(events) => {
510 controls.iter().find_map(|control| {
511 events
512 .iter()
513 .any(|event| event.cid.0 == control.cid)
514 .then_some(control.host)
515 })
516 }
517 reprog_controls::ReprogControlsEvent::DivertedRawMouseXy { .. }
518 | reprog_controls::ReprogControlsEvent::DivertedRawWheel { .. } => None,
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use std::sync::Arc;
525
526 use hidpp::channel::HidppChannel;
527
528 use super::{
529 ArmedControl, HostSwitchError, ReportingMode, event_host, host_change_required,
530 host_channel, prepare_host_change_on, restoration_change, shares_channel,
531 };
532 use crate::DeviceRoute;
533 use crate::channel::scripted::{ScriptedRawHidChannel, feature_error};
534 use crate::reprog_controls::{
535 AnalyticsKeyEvent, CidReporting, ControlId, CtrlIdInfo, ReprogControlsEvent,
536 };
537
538 const CHANGE_HOST_INDEX: u8 = 0x04;
540 const HOSTS_INFO_INDEX: u8 = 0x05;
542
543 const BUSY: u8 = 0x08;
545
546 #[derive(Clone, Copy, PartialEq, Eq)]
548 enum SlotStatus {
549 Reported,
551 Unimplemented,
553 LookupErrors,
556 ReadErrors,
558 }
559
560 fn keyboard_with_an_empty_third_slot(request: &[u8]) -> Option<Vec<u8>> {
564 scripted_keyboard(request, SlotStatus::Reported)
565 }
566
567 fn keyboard_without_hosts_info(request: &[u8]) -> Option<Vec<u8>> {
569 scripted_keyboard(request, SlotStatus::Unimplemented)
570 }
571
572 fn keyboard_erroring_on_hosts_info_lookup(request: &[u8]) -> Option<Vec<u8>> {
574 scripted_keyboard(request, SlotStatus::LookupErrors)
575 }
576
577 fn keyboard_erroring_on_slot_status(request: &[u8]) -> Option<Vec<u8>> {
579 scripted_keyboard(request, SlotStatus::ReadErrors)
580 }
581
582 fn scripted_keyboard(request: &[u8], slot_status: SlotStatus) -> Option<Vec<u8>> {
583 if request.len() < 7 || !matches!(request[0], 0x10 | 0x11) {
584 return None;
585 }
586 let mut payload = [0u8; 16];
587 match (request[2], request[3] >> 4) {
588 (0x00, 0x01) => payload[0] = 4,
590 (0x00, 0x00) => {
592 payload[0] = match u16::from_be_bytes([request[4], request[5]]) {
593 0x1814 => CHANGE_HOST_INDEX,
594 0x1815 => match slot_status {
595 SlotStatus::Unimplemented => 0x00,
596 SlotStatus::LookupErrors => return Some(feature_error(request, BUSY)),
597 SlotStatus::Reported | SlotStatus::ReadErrors => HOSTS_INFO_INDEX,
598 },
599 _ => 0x00,
600 };
601 }
602 (CHANGE_HOST_INDEX, 0x00) => payload[..2].copy_from_slice(&[3, 0]),
604 (HOSTS_INFO_INDEX, 0x01) => {
606 if slot_status == SlotStatus::ReadErrors {
607 return Some(feature_error(request, BUSY));
608 }
609 payload[0] = request[4];
610 payload[1] = u8::from(request[4] < 2);
611 }
612 _ => return None,
613 }
614
615 let mut response = vec![0u8; 7];
616 response[0] = 0x10;
617 response[1..4].copy_from_slice(&request[1..4]);
618 response[4..].copy_from_slice(&payload[..3]);
619 Some(response)
620 }
621
622 async fn scripted_channel(responder: crate::channel::scripted::Responder) -> Arc<HidppChannel> {
623 let (raw, _handle) = ScriptedRawHidChannel::with_responder(responder);
624 crate::channel::scripted::scripted_channel(raw).await
625 }
626
627 #[tokio::test]
628 async fn switching_to_an_unpaired_slot_is_refused() {
629 let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
633
634 let Err(error) = prepare_host_change_on(&channel, 1, 2).await else {
635 panic!("an unpaired slot must not be switched to");
636 };
637
638 assert!(
639 matches!(error, HostSwitchError::HostSlotEmpty { host: 2 }),
640 "got {error:?}"
641 );
642 }
643
644 #[tokio::test]
645 async fn switching_to_a_paired_slot_proceeds() {
646 let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
647
648 let change = prepare_host_change_on(&channel, 1, 1)
649 .await
650 .expect("a paired slot must be switchable");
651
652 assert!(change.required, "host 1 differs from the current host 0");
653 }
654
655 #[tokio::test]
656 async fn a_device_without_hosts_info_is_still_switched() {
657 let channel = scripted_channel(keyboard_without_hosts_info).await;
661
662 let change = prepare_host_change_on(&channel, 1, 2)
663 .await
664 .expect("a device that cannot report slot status must still switch");
665
666 assert!(change.required);
667 }
668
669 #[tokio::test]
670 async fn a_failed_hosts_info_lookup_does_not_block_the_switch() {
671 let channel = scripted_channel(keyboard_erroring_on_hosts_info_lookup).await;
675
676 let change = prepare_host_change_on(&channel, 1, 2)
677 .await
678 .expect("an errored feature lookup must not abort the switch");
679
680 assert!(change.required);
681 }
682
683 #[tokio::test]
684 async fn an_unreadable_slot_status_does_not_block_the_switch() {
685 let channel = scripted_channel(keyboard_erroring_on_slot_status).await;
689
690 let change = prepare_host_change_on(&channel, 1, 2)
691 .await
692 .expect("an errored status read must not abort the switch");
693
694 assert!(change.required);
695 }
696
697 #[tokio::test]
698 async fn a_switch_to_the_current_host_never_consults_slot_status() {
699 let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
702
703 let change = prepare_host_change_on(&channel, 1, 0)
704 .await
705 .expect("staying on the current host is always fine");
706
707 assert!(!change.required);
708 }
709
710 fn reporting(diverted: bool, raw_xy: bool, analytics_key_events: bool) -> CidReporting {
711 CidReporting {
712 cid: ControlId(0x00d3),
713 diverted,
714 persistently_diverted: true,
715 force_raw_xy: true,
716 raw_xy,
717 remap: Some(ControlId(0x1234)),
718 analytics_key_events,
719 raw_wheel: true,
720 }
721 }
722
723 #[test]
724 fn receiver_slots_share_one_channel() {
725 let keyboard = DeviceRoute::Bolt {
726 receiver_uid: "AABB".into(),
727 slot: 1,
728 };
729 let mouse = DeviceRoute::Bolt {
730 receiver_uid: "aabb".into(),
731 slot: 2,
732 };
733 assert!(shares_channel(&keyboard, &mouse));
734 }
735
736 #[test]
737 fn direct_devices_do_not_share_channels() {
738 let route = DeviceRoute::Direct {
739 vendor_id: 0x046d,
740 product_id: 0xb025,
741 };
742 assert!(!shares_channel(&route, &route));
743 }
744
745 #[test]
746 fn host_controls_are_recognized_by_task_when_cid_varies() {
747 let info = CtrlIdInfo {
748 cid: 0x1234,
749 task_id: 0x00af,
750 flags: 0,
751 };
752 assert_eq!(host_channel(info), Some(1));
753 }
754
755 #[test]
756 fn analytics_event_selects_the_matching_host() {
757 let controls = [ArmedControl {
758 cid: 0x00d3,
759 host: 2,
760 mode: ReportingMode::Analytics,
761 original: reporting(false, false, false),
762 }];
763 let mut events = [AnalyticsKeyEvent::default(); 5];
764 events[0] = AnalyticsKeyEvent {
765 cid: ControlId(0x00d3),
766 event: 1,
767 };
768 assert_eq!(
769 event_host(&controls, ReprogControlsEvent::AnalyticsKeyEvents(events)),
770 Some(2)
771 );
772 }
773
774 #[test]
775 fn current_host_does_not_require_a_change() {
776 assert!(matches!(host_change_required(1, 3, 1), Ok(false)));
777 }
778
779 #[test]
780 fn different_valid_host_requires_a_change() {
781 assert!(matches!(host_change_required(0, 3, 2), Ok(true)));
782 }
783
784 #[test]
785 fn host_outside_device_range_is_rejected() {
786 assert!(
787 host_change_required(0, 2, 2).is_err(),
788 "host 2 is outside a device that reports 2 hosts and must be rejected"
789 );
790 }
791
792 #[test]
793 fn diverted_cleanup_restores_only_the_original_temporary_bits() {
794 let change = restoration_change(ArmedControl {
795 cid: 0x00d3,
796 host: 2,
797 mode: ReportingMode::Diverted,
798 original: reporting(true, true, false),
799 });
800
801 assert_eq!(change.diverted, Some(true));
802 assert_eq!(change.raw_xy, Some(true));
803 assert_eq!(change.analytics_key_events, None);
804 assert_eq!(change.persistently_diverted, None);
805 assert_eq!(change.remap, None);
806 }
807
808 #[test]
809 fn analytics_cleanup_restores_the_original_analytics_bit() {
810 let change = restoration_change(ArmedControl {
811 cid: 0x00d3,
812 host: 2,
813 mode: ReportingMode::Analytics,
814 original: reporting(false, false, true),
815 });
816
817 assert_eq!(change.analytics_key_events, Some(true));
818 assert_eq!(change.diverted, None);
819 assert_eq!(change.raw_xy, None);
820 }
821}