Skip to main content

openipc_web/
lib.rs

1use js_sys::{Array, Object, Reflect, Uint8Array};
2use openipc_core::ieee80211::WifiFrame;
3use openipc_core::realtek::{parse_rx_aggregate, RxPacketType};
4#[cfg(target_arch = "wasm32")]
5use openipc_core::realtek_tx::RealtekTxOptions;
6use openipc_core::{
7    AdaptiveLinkSender, ChannelId, Codec, DepacketizedFrame, FecCounters, FrameLayout,
8    PayloadPipeline, PayloadPipelineEvent, PipelineEvent, RadioPort, ReceiverPipeline, WfbKeypair,
9    WfbTxKeypair,
10};
11#[cfg(target_arch = "wasm32")]
12use openipc_rtl88xx::{
13    ChannelWidth, DriverOptions, FalseAlarmCounters, Firmware8814Mode, InitReport, InitStatus,
14    IqkReport, MonitorOptions, PhydmDigState, PhydmWatchdogReport, PowerTrackingReport,
15    PowerTrackingState, RadioConfig, RealtekDevice, ThermalBucket,
16};
17use wasm_bindgen::prelude::*;
18
19#[wasm_bindgen(typescript_custom_section)]
20const OPENIPC_VIDEO_FRAME_TYPES: &'static str = r#"
21export type OpenIpcVideoFrame = {
22    data: Uint8Array;
23    codec: "h264" | "h265";
24    codecString: string;
25    isKeyFrame: boolean;
26    timestamp: number;
27};
28
29export type OpenIpcRawPayload = {
30    data: Uint8Array;
31    packetSeq: string;
32    channelId: number;
33};
34
35export type OpenIpcRxTransferProfile = {
36    frames: OpenIpcVideoFrame[];
37    mavlinkPayloads: OpenIpcRawPayload[];
38    transferBytes: number;
39    packets: number;
40    acceptedPackets: number;
41    droppedPackets: number;
42    crcDropped: number;
43    icvDropped: number;
44    reportDropped: number;
45    ignoredFrames: number;
46    sessions: number;
47    wfbPayloads: number;
48    rtpPackets: number;
49    videoFrames: number;
50    mavlinkPayloadCount: number;
51    mavlinkBytes: number;
52    parseMs: number;
53    pipelineMs: number;
54    totalMs: number;
55};
56"#;
57
58#[wasm_bindgen]
59pub struct OpenIpcReceiver {
60    pipeline: ReceiverPipeline,
61    mavlink_pipeline: Option<PayloadPipeline>,
62}
63
64#[wasm_bindgen]
65impl OpenIpcReceiver {
66    #[wasm_bindgen(constructor)]
67    pub fn new() -> Result<OpenIpcReceiver, JsValue> {
68        Self::with_channel_id(openipc_core::channel::DEFAULT_LINK_ID << 8, 1, 5)
69    }
70
71    #[wasm_bindgen(js_name = withChannelId)]
72    pub fn with_channel_id(
73        channel_id: u32,
74        fec_k: usize,
75        fec_n: usize,
76    ) -> Result<OpenIpcReceiver, JsValue> {
77        let pipeline = ReceiverPipeline::new(
78            ChannelId::new(channel_id),
79            FrameLayout::WithFcs,
80            fec_k,
81            fec_n,
82        )
83        .map_err(|err| JsValue::from_str(&format!("invalid receiver config: {err:?}")))?;
84        Ok(Self {
85            pipeline,
86            mavlink_pipeline: None,
87        })
88    }
89
90    #[wasm_bindgen(js_name = withKeypair)]
91    pub fn with_keypair(
92        channel_id: u32,
93        keypair: &[u8],
94        minimum_epoch: u64,
95    ) -> Result<OpenIpcReceiver, JsValue> {
96        let keypair = WfbKeypair::from_bytes(keypair)
97            .map_err(|err| JsValue::from_str(&format!("invalid WFB keypair: {err}")))?;
98        let mavlink_channel_id =
99            ChannelId::from_link_port(channel_id >> 8, RadioPort::MavlinkRx).raw();
100        openipc_receiver_with_keypair_and_mavlink_channel_inner(
101            channel_id,
102            mavlink_channel_id,
103            keypair,
104            minimum_epoch,
105        )
106    }
107
108    #[wasm_bindgen(js_name = withKeypairAndMavlinkChannel)]
109    pub fn with_keypair_and_mavlink_channel(
110        channel_id: u32,
111        mavlink_channel_id: u32,
112        keypair: &[u8],
113        minimum_epoch: u64,
114    ) -> Result<OpenIpcReceiver, JsValue> {
115        let keypair = WfbKeypair::from_bytes(keypair)
116            .map_err(|err| JsValue::from_str(&format!("invalid WFB keypair: {err}")))?;
117        openipc_receiver_with_keypair_and_mavlink_channel_inner(
118            channel_id,
119            mavlink_channel_id,
120            keypair,
121            minimum_epoch,
122        )
123    }
124
125    #[wasm_bindgen(js_name = pushRtpPacket)]
126    pub fn push_rtp_packet(&mut self, data: &[u8]) -> Option<Uint8Array> {
127        self.pipeline
128            .push_rtp(data)
129            .map(|frame| Uint8Array::from(frame.data.as_slice()))
130    }
131
132    #[wasm_bindgen(
133        js_name = pushRtpPacketDetailed,
134        unchecked_return_type = "OpenIpcVideoFrame | null"
135    )]
136    pub fn push_rtp_packet_detailed(&mut self, data: &[u8]) -> Result<JsValue, JsValue> {
137        match self.pipeline.push_rtp(data) {
138            Some(frame) => Ok(video_frame_object(frame)?.into()),
139            None => Ok(JsValue::NULL),
140        }
141    }
142
143    #[wasm_bindgen(js_name = pushDecryptedFragment)]
144    pub fn push_decrypted_fragment(
145        &mut self,
146        data_nonce_hex: &str,
147        fragment: &[u8],
148    ) -> Result<Array, JsValue> {
149        let data_nonce = parse_hex_u64(data_nonce_hex)?;
150        let events = self
151            .pipeline
152            .push_decrypted_fragment(data_nonce, fragment)
153            .map_err(|err| JsValue::from_str(&format!("WFB fragment rejected: {err:?}")))?;
154
155        let frames = Array::new();
156        for event in events {
157            if let PipelineEvent::VideoFrame(frame) = event {
158                frames.push(&Uint8Array::from(frame.data.as_slice()));
159            }
160        }
161        Ok(frames)
162    }
163
164    #[wasm_bindgen(js_name = pushDecrypted80211Frame)]
165    pub fn push_decrypted_80211_frame(
166        &mut self,
167        frame: &[u8],
168        fragment: &[u8],
169    ) -> Result<Array, JsValue> {
170        let events = self
171            .pipeline
172            .push_decrypted_80211_frame(frame, fragment)
173            .map_err(|err| JsValue::from_str(&format!("802.11 frame rejected: {err:?}")))?;
174        let frames = Array::new();
175        for event in events {
176            if let PipelineEvent::VideoFrame(frame) = event {
177                frames.push(&Uint8Array::from(frame.data.as_slice()));
178            }
179        }
180        Ok(frames)
181    }
182
183    #[wasm_bindgen(js_name = pushEncrypted80211Frame)]
184    pub fn push_encrypted_80211_frame(&mut self, frame: &[u8]) -> Result<Array, JsValue> {
185        let events = self
186            .pipeline
187            .push_80211_frame(frame)
188            .map_err(|err| JsValue::from_str(&format!("802.11 frame rejected: {err}")))?;
189        Ok(video_frames_from_events(events))
190    }
191
192    #[wasm_bindgen(js_name = pushRxTransfer)]
193    pub fn push_rx_transfer(&mut self, transfer: &[u8]) -> Result<Array, JsValue> {
194        let packets = parse_rx_aggregate(transfer)
195            .map_err(|err| JsValue::from_str(&format!("Realtek RX aggregate rejected: {err}")))?;
196        let frames = Array::new();
197        for packet in packets {
198            if packet.attrib.crc_err
199                || packet.attrib.icv_err
200                || packet.attrib.pkt_rpt_type != RxPacketType::NormalRx
201            {
202                continue;
203            }
204            let events = self
205                .pipeline
206                .push_80211_frame(packet.data)
207                .map_err(|err| JsValue::from_str(&format!("OpenIPC frame rejected: {err}")))?;
208            append_video_frames(&frames, events);
209        }
210        Ok(frames)
211    }
212
213    #[wasm_bindgen(
214        js_name = pushRxTransferDetailed,
215        unchecked_return_type = "OpenIpcVideoFrame[]"
216    )]
217    pub fn push_rx_transfer_detailed(&mut self, transfer: &[u8]) -> Result<Array, JsValue> {
218        self.push_rx_transfer_detailed_with_options(transfer, false)
219    }
220
221    #[wasm_bindgen(
222        js_name = pushRxTransferDetailedWithOptions,
223        unchecked_return_type = "OpenIpcVideoFrame[]"
224    )]
225    pub fn push_rx_transfer_detailed_with_options(
226        &mut self,
227        transfer: &[u8],
228        keep_corrupted: bool,
229    ) -> Result<Array, JsValue> {
230        let packets = parse_rx_aggregate(transfer)
231            .map_err(|err| JsValue::from_str(&format!("Realtek RX aggregate rejected: {err}")))?;
232        let frames = Array::new();
233        for packet in packets {
234            if !accept_rx_packet(packet.attrib, keep_corrupted) {
235                continue;
236            }
237            let events = self
238                .pipeline
239                .push_80211_frame(packet.data)
240                .map_err(|err| JsValue::from_str(&format!("OpenIPC frame rejected: {err}")))?;
241            append_video_frame_objects(&frames, events)?;
242        }
243        Ok(frames)
244    }
245
246    #[wasm_bindgen(
247        js_name = pushRxTransferProfiled,
248        unchecked_return_type = "OpenIpcRxTransferProfile"
249    )]
250    pub fn push_rx_transfer_profiled(&mut self, transfer: &[u8]) -> Result<Object, JsValue> {
251        self.push_rx_transfer_profiled_with_options(transfer, false)
252    }
253
254    #[wasm_bindgen(
255        js_name = pushRxTransferProfiledWithOptions,
256        unchecked_return_type = "OpenIpcRxTransferProfile"
257    )]
258    pub fn push_rx_transfer_profiled_with_options(
259        &mut self,
260        transfer: &[u8],
261        keep_corrupted: bool,
262    ) -> Result<Object, JsValue> {
263        let total_start = now_ms();
264        let parse_start = now_ms();
265        let packets = parse_rx_aggregate(transfer)
266            .map_err(|err| JsValue::from_str(&format!("Realtek RX aggregate rejected: {err}")))?;
267        let parse_ms = elapsed_ms(parse_start);
268
269        let frames = Array::new();
270        let mavlink_payloads = Array::new();
271        let mut accepted_packets = 0usize;
272        let mut crc_dropped = 0usize;
273        let mut icv_dropped = 0usize;
274        let mut report_dropped = 0usize;
275        let mut ignored_frames = 0usize;
276        let mut sessions = 0usize;
277        let mut wfb_payloads = 0usize;
278        let mut rtp_packets = 0usize;
279        let mut video_frames = 0usize;
280        let mut mavlink_payload_count = 0usize;
281        let mut mavlink_bytes = 0usize;
282
283        let pipeline_start = now_ms();
284        let packet_count = packets.len();
285        for packet in packets {
286            if packet.attrib.crc_err && !keep_corrupted {
287                crc_dropped += 1;
288                continue;
289            }
290            if packet.attrib.icv_err && !keep_corrupted {
291                icv_dropped += 1;
292                continue;
293            }
294            if packet.attrib.pkt_rpt_type != RxPacketType::NormalRx {
295                report_dropped += 1;
296                continue;
297            }
298            accepted_packets += 1;
299            if let Some(mavlink_pipeline) = self.mavlink_pipeline.as_mut() {
300                if let Ok(events) = mavlink_pipeline.push_80211_frame(packet.data) {
301                    append_payload_objects(
302                        &mavlink_payloads,
303                        events,
304                        mavlink_pipeline.channel_id(),
305                        &mut mavlink_payload_count,
306                        &mut mavlink_bytes,
307                    )?;
308                }
309            }
310            let events = self
311                .pipeline
312                .push_80211_frame(packet.data)
313                .map_err(|err| JsValue::from_str(&format!("OpenIPC frame rejected: {err}")))?;
314            for event in events {
315                match event {
316                    PipelineEvent::IgnoredFrame => ignored_frames += 1,
317                    PipelineEvent::SessionEstablished { .. } => sessions += 1,
318                    PipelineEvent::WfbPayload { .. } => wfb_payloads += 1,
319                    PipelineEvent::RtpPacket { .. } => rtp_packets += 1,
320                    PipelineEvent::VideoFrame(frame) => {
321                        video_frames += 1;
322                        frames.push(&video_frame_object(frame)?.into());
323                    }
324                }
325            }
326        }
327        let pipeline_ms = elapsed_ms(pipeline_start);
328
329        let object = Object::new();
330        Reflect::set(&object, &JsValue::from_str("frames"), &frames)?;
331        Reflect::set(
332            &object,
333            &JsValue::from_str("mavlinkPayloads"),
334            &mavlink_payloads,
335        )?;
336        set_number(&object, "transferBytes", transfer.len() as f64)?;
337        set_number(&object, "packets", packet_count as f64)?;
338        set_number(&object, "acceptedPackets", accepted_packets as f64)?;
339        set_number(
340            &object,
341            "droppedPackets",
342            (crc_dropped + icv_dropped + report_dropped) as f64,
343        )?;
344        set_number(&object, "crcDropped", crc_dropped as f64)?;
345        set_number(&object, "icvDropped", icv_dropped as f64)?;
346        set_number(&object, "reportDropped", report_dropped as f64)?;
347        set_number(&object, "ignoredFrames", ignored_frames as f64)?;
348        set_number(&object, "sessions", sessions as f64)?;
349        set_number(&object, "wfbPayloads", wfb_payloads as f64)?;
350        set_number(&object, "rtpPackets", rtp_packets as f64)?;
351        set_number(&object, "videoFrames", video_frames as f64)?;
352        set_number(&object, "mavlinkPayloadCount", mavlink_payload_count as f64)?;
353        set_number(&object, "mavlinkBytes", mavlink_bytes as f64)?;
354        set_number(&object, "parseMs", parse_ms)?;
355        set_number(&object, "pipelineMs", pipeline_ms)?;
356        set_number(&object, "totalMs", elapsed_ms(total_start))?;
357        Ok(object)
358    }
359
360    #[wasm_bindgen(js_name = fecCounters)]
361    pub fn fec_counters(&self) -> String {
362        counters_json(self.pipeline.fec_counters())
363    }
364}
365
366#[wasm_bindgen]
367pub struct OpenIpcAdaptiveLink {
368    sender: AdaptiveLinkSender,
369    last_counters: FecCounters,
370    rx_channel_id: ChannelId,
371}
372
373#[wasm_bindgen]
374impl OpenIpcAdaptiveLink {
375    #[wasm_bindgen(constructor)]
376    pub fn new(
377        link_id: u32,
378        keypair: &[u8],
379        epoch: u64,
380        fec_k: usize,
381        fec_n: usize,
382    ) -> Result<OpenIpcAdaptiveLink, JsValue> {
383        let keypair = WfbTxKeypair::from_bytes(keypair)
384            .map_err(|err| JsValue::from_str(&format!("invalid adaptive-link keypair: {err}")))?;
385        let sender = AdaptiveLinkSender::new(link_id, keypair, epoch, fec_k, fec_n)
386            .map_err(|err| JsValue::from_str(&format!("invalid adaptive-link config: {err}")))?;
387        Ok(Self {
388            sender,
389            last_counters: FecCounters::default(),
390            rx_channel_id: ChannelId::from_link_port(link_id, RadioPort::Video),
391        })
392    }
393
394    #[wasm_bindgen(js_name = recordRx)]
395    pub fn record_rx(&mut self, now_ms: f64, rssi0: u8, rssi1: u8, snr0: i8, snr1: i8) {
396        self.sender
397            .link_mut()
398            .record_rx(ms_from_js(now_ms), rssi0, rssi1, snr0, snr1);
399    }
400
401    #[wasm_bindgen(js_name = recordRxTransfer)]
402    pub fn record_rx_transfer(&mut self, transfer: &[u8], now_ms: f64) -> Result<(), JsValue> {
403        let packets = parse_rx_aggregate(transfer)
404            .map_err(|err| JsValue::from_str(&format!("Realtek RX aggregate rejected: {err}")))?;
405        let now_ms = ms_from_js(now_ms);
406        for packet in packets {
407            if packet.attrib.crc_err
408                || packet.attrib.icv_err
409                || packet.attrib.pkt_rpt_type != RxPacketType::NormalRx
410            {
411                continue;
412            }
413            if !WifiFrame::parse(packet.data, FrameLayout::WithFcs)
414                .map(|frame| frame.matches_channel_id(self.rx_channel_id))
415                .unwrap_or(false)
416            {
417                continue;
418            }
419            self.sender
420                .record_rx_paths(now_ms, packet.attrib.rssi, packet.attrib.snr);
421        }
422        Ok(())
423    }
424
425    #[wasm_bindgen(js_name = recordReceiverCounters)]
426    pub fn record_receiver_counters(&mut self, receiver: &OpenIpcReceiver, now_ms: f64) {
427        self.record_counter_delta(ms_from_js(now_ms), receiver.pipeline.fec_counters());
428    }
429
430    #[wasm_bindgen(js_name = recordFec)]
431    pub fn record_fec(&mut self, now_ms: f64, total: u32, recovered: u32, lost: u32) {
432        self.sender
433            .record_fec(ms_from_js(now_ms), total, recovered, lost);
434    }
435
436    #[wasm_bindgen(js_name = requestKeyframe)]
437    pub fn request_keyframe(&mut self) {
438        self.sender.link_mut().request_keyframe();
439    }
440
441    #[wasm_bindgen(js_name = setKeyframeRequestMessages)]
442    pub fn set_keyframe_request_messages(&mut self, messages: u32) {
443        self.sender
444            .link_mut()
445            .set_keyframe_request_messages(messages);
446    }
447
448    #[wasm_bindgen(js_name = setVideoStartIdleMs)]
449    pub fn set_video_start_idle_ms(&mut self, idle_ms: u32) {
450        self.sender
451            .link_mut()
452            .set_video_start_idle_ms(idle_ms as u64);
453    }
454
455    #[wasm_bindgen(js_name = tick)]
456    pub fn tick(&mut self, now_ms: f64) -> Result<Array, JsValue> {
457        let frames = self
458            .sender
459            .tick(ms_from_js(now_ms))
460            .map_err(|err| JsValue::from_str(&format!("adaptive-link tick failed: {err}")))?;
461        let out = Array::new();
462        for frame in frames {
463            out.push(&Uint8Array::from(frame.as_slice()));
464        }
465        Ok(out)
466    }
467
468    #[wasm_bindgen(js_name = counters)]
469    pub fn counters(&self) -> String {
470        counters_json(self.last_counters)
471    }
472
473    #[wasm_bindgen(js_name = quality)]
474    pub fn quality(&mut self, now_ms: f64) -> String {
475        let quality = self.sender.link_mut().quality(ms_from_js(now_ms));
476        format!(
477            r#"{{"lostLastSecond":{},"recoveredLastSecond":{},"totalLastSecond":{},"rssi":[{},{}],"snr":[{},{}],"linkScore":[{},{}],"idrCode":"{}"}}"#,
478            quality.lost_last_second,
479            quality.recovered_last_second,
480            quality.total_last_second,
481            quality.rssi[0],
482            quality.rssi[1],
483            quality.snr[0],
484            quality.snr[1],
485            quality.link_score[0],
486            quality.link_score[1],
487            escape_json_str(&quality.idr_code),
488        )
489    }
490
491    fn record_counter_delta(&mut self, now_ms: u64, counters: FecCounters) {
492        let total = counters
493            .total_packets
494            .saturating_sub(self.last_counters.total_packets);
495        let recovered = counters
496            .recovered_packets
497            .saturating_sub(self.last_counters.recovered_packets);
498        let lost = counters
499            .lost_packets
500            .saturating_sub(self.last_counters.lost_packets);
501        self.last_counters = counters;
502        self.sender.record_fec(
503            now_ms,
504            total.min(u32::MAX as u64) as u32,
505            recovered.min(u32::MAX as u64) as u32,
506            lost.min(u32::MAX as u64) as u32,
507        );
508    }
509}
510
511#[cfg(target_arch = "wasm32")]
512#[wasm_bindgen]
513impl OpenIpcAdaptiveLink {
514    #[wasm_bindgen(js_name = tickAndSend)]
515    pub async fn tick_and_send(
516        &mut self,
517        device: &WebUsbRealtekDevice,
518        now_ms: f64,
519        current_channel: u8,
520    ) -> Result<usize, JsValue> {
521        let frames = self
522            .sender
523            .tick(ms_from_js(now_ms))
524            .map_err(|err| JsValue::from_str(&format!("adaptive-link tick failed: {err}")))?;
525        let count = frames.len();
526        for frame in frames {
527            device.send_packet(&frame, current_channel).await?;
528        }
529        Ok(count)
530    }
531}
532
533#[cfg(target_arch = "wasm32")]
534#[wasm_bindgen]
535pub struct WebUsbRealtekDevice {
536    driver: RealtekDevice,
537}
538
539#[cfg(target_arch = "wasm32")]
540#[wasm_bindgen]
541impl WebUsbRealtekDevice {
542    #[wasm_bindgen(js_name = fromWebUsbDevice)]
543    pub async fn from_web_usb_device(
544        device: web_sys::UsbDevice,
545    ) -> Result<WebUsbRealtekDevice, JsValue> {
546        let driver = RealtekDevice::from_web_usb_device(device)
547            .await
548            .map_err(driver_error)?;
549        Ok(Self { driver })
550    }
551
552    #[wasm_bindgen(js_name = fromWebUsbDeviceWithOptions)]
553    pub async fn from_web_usb_device_with_options(
554        device: web_sys::UsbDevice,
555        tx_endpoint_override: i32,
556    ) -> Result<WebUsbRealtekDevice, JsValue> {
557        Self::from_web_usb_device_advanced(device, tx_endpoint_override, -1, -1).await
558    }
559
560    #[wasm_bindgen(js_name = fromWebUsbDeviceAdvanced)]
561    pub async fn from_web_usb_device_advanced(
562        device: web_sys::UsbDevice,
563        tx_endpoint_override: i32,
564        target_vendor_id: i32,
565        target_product_id: i32,
566    ) -> Result<WebUsbRealtekDevice, JsValue> {
567        let driver = RealtekDevice::from_web_usb_device_with_options(
568            device,
569            DriverOptions {
570                tx_endpoint_override: optional_u8(tx_endpoint_override, "txEndpointOverride")?,
571                target_vendor_id: optional_u16(target_vendor_id, "targetVendorId")?,
572                target_product_id: optional_u16(target_product_id, "targetProductId")?,
573                ..DriverOptions::default()
574            },
575        )
576        .await
577        .map_err(driver_error)?;
578        Ok(Self { driver })
579    }
580
581    #[wasm_bindgen(js_name = bulkInEndpoint)]
582    pub fn bulk_in_endpoint(&self) -> u8 {
583        self.driver.bulk_in_ep
584    }
585
586    #[wasm_bindgen(js_name = bulkOutEndpoint)]
587    pub fn bulk_out_endpoint(&self) -> u8 {
588        self.driver.bulk_out_ep
589    }
590
591    #[wasm_bindgen(js_name = initializeMonitor)]
592    pub async fn initialize_monitor(
593        &self,
594        channel: u8,
595        channel_width_mhz: u16,
596        channel_offset: u8,
597    ) -> Result<String, JsValue> {
598        self.initialize_monitor_with_options(channel, channel_width_mhz, channel_offset, false)
599            .await
600    }
601
602    #[wasm_bindgen(js_name = initializeMonitorWithOptions)]
603    pub async fn initialize_monitor_with_options(
604        &self,
605        channel: u8,
606        channel_width_mhz: u16,
607        channel_offset: u8,
608        accept_bad_fcs: bool,
609    ) -> Result<String, JsValue> {
610        let radio = RadioConfig {
611            channel,
612            channel_offset,
613            channel_width: parse_channel_width(channel_width_mhz)?,
614        };
615        let report = self
616            .driver
617            .initialize_monitor_async(radio, accept_bad_fcs)
618            .await
619            .map_err(driver_error)?;
620        Ok(init_report_json(&report))
621    }
622
623    #[wasm_bindgen(js_name = initializeMonitorAdvanced)]
624    pub async fn initialize_monitor_advanced(
625        &self,
626        channel: u8,
627        channel_width_mhz: u16,
628        channel_offset: u8,
629        accept_bad_fcs: bool,
630        skip_tx_power: bool,
631        force_iqk: bool,
632        disable_iqk: bool,
633        firmware_8814_mode: String,
634        firmware_8814_chunk: i32,
635    ) -> Result<String, JsValue> {
636        let radio = RadioConfig {
637            channel,
638            channel_offset,
639            channel_width: parse_channel_width(channel_width_mhz)?,
640        };
641        let mode = if firmware_8814_mode.trim().is_empty() {
642            Firmware8814Mode::Kernel
643        } else {
644            Firmware8814Mode::from_env_value(&firmware_8814_mode).ok_or_else(|| {
645                JsValue::from_str("firmware8814Mode must be \"kernel\" or \"rtw88\"")
646            })?
647        };
648        let options = MonitorOptions {
649            accept_bad_fcs,
650            skip_tx_power,
651            force_iqk,
652            disable_iqk,
653            firmware_8814_mode: mode,
654            firmware_8814_chunk: optional_usize(firmware_8814_chunk, "firmware8814Chunk")?,
655        };
656        let report = self
657            .driver
658            .initialize_monitor_with_options_async(radio, options)
659            .await
660            .map_err(driver_error)?;
661        Ok(init_report_json(&report))
662    }
663
664    #[wasm_bindgen(js_name = readRxTransfer)]
665    pub async fn read_rx_transfer(&self, length: usize) -> Result<Uint8Array, JsValue> {
666        let bytes = self
667            .driver
668            .read_rx_transfer_async(length)
669            .await
670            .map_err(driver_error)?;
671        Ok(Uint8Array::from(bytes.as_slice()))
672    }
673
674    #[wasm_bindgen(js_name = readRxTransfers)]
675    pub async fn read_rx_transfers(
676        &self,
677        length: usize,
678        in_flight: usize,
679    ) -> Result<Array, JsValue> {
680        let transfers = self
681            .driver
682            .read_rx_transfers_async(length, in_flight)
683            .await
684            .map_err(driver_error)?;
685        let out = Array::new();
686        for transfer in transfers {
687            out.push(&Uint8Array::from(transfer.as_slice()));
688        }
689        Ok(out)
690    }
691
692    #[wasm_bindgen(js_name = writeTxTransfer)]
693    pub async fn write_tx_transfer(&self, transfer: &[u8]) -> Result<usize, JsValue> {
694        self.driver
695            .write_tx_transfer_async(transfer)
696            .await
697            .map_err(driver_error)
698    }
699
700    #[wasm_bindgen(js_name = sendPacket)]
701    pub async fn send_packet(
702        &self,
703        radiotap_packet: &[u8],
704        current_channel: u8,
705    ) -> Result<usize, JsValue> {
706        self.send_packet_with_options(radiotap_packet, current_channel, false)
707            .await
708    }
709
710    #[wasm_bindgen(js_name = sendPacketWithOptions)]
711    pub async fn send_packet_with_options(
712        &self,
713        radiotap_packet: &[u8],
714        current_channel: u8,
715        legacy_8812_descriptor: bool,
716    ) -> Result<usize, JsValue> {
717        let chip = self.driver.probe_chip_async().await.map_err(driver_error)?;
718        self.driver
719            .send_packet_async(
720                radiotap_packet,
721                RealtekTxOptions {
722                    current_channel,
723                    is_8814a: chip.family == openipc_rtl88xx::ChipFamily::Rtl8814,
724                    legacy_8812_descriptor,
725                    ..RealtekTxOptions::default()
726                },
727            )
728            .await
729            .map_err(driver_error)
730    }
731
732    #[wasm_bindgen(js_name = setTxPowerOverride)]
733    pub async fn set_tx_power_override(
734        &self,
735        current_channel: u8,
736        power: u8,
737    ) -> Result<(), JsValue> {
738        self.driver
739            .set_tx_power_override_async(current_channel, power)
740            .await
741            .map_err(driver_error)
742    }
743
744    #[wasm_bindgen(js_name = readThermalStatus)]
745    pub async fn read_thermal_status(&self) -> Result<String, JsValue> {
746        let status = self
747            .driver
748            .read_thermal_status_async()
749            .await
750            .map_err(driver_error)?;
751        Ok(format!(
752            r#"{{"raw":{},"baseline":{},"delta":{},"valid":{},"bucket":"{}"}}"#,
753            status.raw,
754            status.baseline,
755            status.delta,
756            status.valid,
757            thermal_bucket_name(status.bucket())
758        ))
759    }
760
761    #[wasm_bindgen(js_name = readQueueDepth8814)]
762    pub async fn read_queue_depth_8814(&self) -> Result<String, JsValue> {
763        let regs = self
764            .driver
765            .read_queue_depth_8814_async()
766            .await
767            .map_err(driver_error)?;
768        Ok(format!(
769            r#"[{},{},{},{},{}]"#,
770            regs[0], regs[1], regs[2], regs[3], regs[4]
771        ))
772    }
773
774    #[wasm_bindgen(js_name = readBbReg)]
775    pub async fn read_bb_reg(&self, register: u16, mask: u32) -> Result<u32, JsValue> {
776        self.driver
777            .read_bb_reg_async(register, mask)
778            .await
779            .map_err(driver_error)
780    }
781
782    #[wasm_bindgen(js_name = readBbDbgport)]
783    pub async fn read_bb_dbgport(&self, selector: u32) -> Result<String, JsValue> {
784        let read = self
785            .driver
786            .read_bb_dbgport_async(selector)
787            .await
788            .map_err(driver_error)?;
789        Ok(format!(
790            r#"{{"selector":{},"value":{},"savedSelector":{},"chipAlive":{}}}"#,
791            read.selector, read.value, read.saved_selector, read.chip_alive
792        ))
793    }
794
795    #[wasm_bindgen(js_name = readFalseAlarmCounters)]
796    pub async fn read_false_alarm_counters(&self) -> Result<String, JsValue> {
797        let counters = self
798            .driver
799            .read_false_alarm_counters_async()
800            .await
801            .map_err(driver_error)?;
802        Ok(false_alarm_counters_json(counters))
803    }
804
805    #[wasm_bindgen(js_name = runIqk)]
806    pub async fn run_iqk(&self, channel: u8) -> Result<String, JsValue> {
807        let chip = self.driver.probe_chip_async().await.map_err(driver_error)?;
808        let report = self
809            .driver
810            .run_iqk_async(chip, channel)
811            .await
812            .map_err(driver_error)?;
813        Ok(iqk_report_json(report))
814    }
815
816    #[wasm_bindgen(js_name = readRegisterU8)]
817    pub async fn read_register_u8(&self, register: u16) -> Result<u8, JsValue> {
818        self.driver
819            .read_u8_async(register)
820            .await
821            .map_err(driver_error)
822    }
823
824    #[wasm_bindgen(js_name = readRegisterU32)]
825    pub async fn read_register_u32(&self, register: u16) -> Result<u32, JsValue> {
826        self.driver
827            .read_u32_async(register)
828            .await
829            .map_err(driver_error)
830    }
831}
832
833#[cfg(target_arch = "wasm32")]
834#[wasm_bindgen]
835pub struct WebUsbPhydmWatchdog {
836    state: PhydmDigState,
837}
838
839#[cfg(target_arch = "wasm32")]
840#[wasm_bindgen]
841impl WebUsbPhydmWatchdog {
842    #[wasm_bindgen(constructor)]
843    pub fn new() -> Self {
844        Self {
845            state: PhydmDigState::default(),
846        }
847    }
848
849    #[wasm_bindgen(js_name = tick)]
850    pub async fn tick(&mut self, device: &WebUsbRealtekDevice) -> Result<String, JsValue> {
851        let report = device
852            .driver
853            .run_phydm_watchdog_tick_async(&mut self.state)
854            .await
855            .map_err(driver_error)?;
856        Ok(phydm_watchdog_report_json(report))
857    }
858}
859
860#[cfg(target_arch = "wasm32")]
861#[wasm_bindgen]
862pub struct WebUsbPowerTracking8812 {
863    state: PowerTrackingState,
864}
865
866#[cfg(target_arch = "wasm32")]
867#[wasm_bindgen]
868impl WebUsbPowerTracking8812 {
869    #[wasm_bindgen(constructor)]
870    pub fn new() -> Self {
871        Self {
872            state: PowerTrackingState::default(),
873        }
874    }
875
876    #[wasm_bindgen(js_name = init)]
877    pub async fn init(&mut self, device: &WebUsbRealtekDevice) -> Result<(), JsValue> {
878        device
879            .driver
880            .init_power_tracking_8812_async(&mut self.state)
881            .await
882            .map_err(driver_error)
883    }
884
885    #[wasm_bindgen(js_name = clear)]
886    pub async fn clear(&mut self, device: &WebUsbRealtekDevice) -> Result<(), JsValue> {
887        device
888            .driver
889            .clear_power_tracking_8812_async(&mut self.state)
890            .await
891            .map_err(driver_error)
892    }
893
894    #[wasm_bindgen(js_name = tick)]
895    pub async fn tick(
896        &mut self,
897        device: &WebUsbRealtekDevice,
898        channel: u8,
899        channel_width_mhz: u16,
900    ) -> Result<String, JsValue> {
901        let report = device
902            .driver
903            .tick_power_tracking_8812_async(
904                &mut self.state,
905                channel,
906                parse_channel_width(channel_width_mhz)?,
907            )
908            .await
909            .map_err(driver_error)?;
910        Ok(power_tracking_report_json(report))
911    }
912}
913
914#[cfg(target_arch = "wasm32")]
915fn thermal_bucket_name(bucket: ThermalBucket) -> &'static str {
916    match bucket {
917        ThermalBucket::Unknown => "unknown",
918        ThermalBucket::Cool => "cool",
919        ThermalBucket::Warm => "warm",
920        ThermalBucket::Hot => "hot",
921        ThermalBucket::Critical => "critical",
922    }
923}
924
925#[cfg(target_arch = "wasm32")]
926fn false_alarm_counters_json(counters: FalseAlarmCounters) -> String {
927    format!(
928        r#"{{"ofdmFail":{},"cckFail":{},"ofdmCca":{},"cckCca":{},"cckCrcOk":{},"cckCrcError":{},"ofdmCrcOk":{},"ofdmCrcError":{},"htCrcOk":{},"htCrcError":{},"vhtCrcOk":{},"vhtCrcError":{},"all":{},"ccaAll":{}}}"#,
929        counters.cnt_ofdm_fail,
930        counters.cnt_cck_fail,
931        counters.cnt_ofdm_cca,
932        counters.cnt_cck_cca,
933        counters.cnt_cck_crc32_ok,
934        counters.cnt_cck_crc32_error,
935        counters.cnt_ofdm_crc32_ok,
936        counters.cnt_ofdm_crc32_error,
937        counters.cnt_ht_crc32_ok,
938        counters.cnt_ht_crc32_error,
939        counters.cnt_vht_crc32_ok,
940        counters.cnt_vht_crc32_error,
941        counters.cnt_all,
942        counters.cnt_cca_all
943    )
944}
945
946#[cfg(target_arch = "wasm32")]
947fn phydm_watchdog_report_json(report: PhydmWatchdogReport) -> String {
948    format!(
949        r#"{{"previousIgi":{},"currentIgi":{},"counters":{}}}"#,
950        report.previous_igi,
951        report.current_igi,
952        false_alarm_counters_json(report.counters)
953    )
954}
955
956#[cfg(target_arch = "wasm32")]
957fn power_tracking_report_json(report: PowerTrackingReport) -> String {
958    format!(
959        r#"{{"enabled":{},"thermalRaw":{},"thermalAverage":{},"eepromThermal":{},"delta":{},"defaultOfdmIndex":{},"finalOfdmIndex":[{},{}],"swingDelta":[{},{}],"applied":{}}}"#,
960        report.enabled,
961        report.thermal_raw,
962        report.thermal_average,
963        report.eeprom_thermal,
964        report.delta,
965        report.default_ofdm_index,
966        report.final_ofdm_index[0],
967        report.final_ofdm_index[1],
968        report.swing_delta[0],
969        report.swing_delta[1],
970        report.applied
971    )
972}
973
974#[cfg(target_arch = "wasm32")]
975fn iqk_report_json(report: IqkReport) -> String {
976    format!(
977        r#"{{"chip":"{}","channel":{},"ran":{}}}"#,
978        report.chip.family.name(),
979        report.channel,
980        report.ran
981    )
982}
983
984#[cfg(target_arch = "wasm32")]
985fn parse_channel_width(width_mhz: u16) -> Result<ChannelWidth, JsValue> {
986    match width_mhz {
987        20 => Ok(ChannelWidth::Mhz20),
988        40 => Ok(ChannelWidth::Mhz40),
989        80 => Ok(ChannelWidth::Mhz80),
990        _ => Err(JsValue::from_str(
991            "unsupported channel width; expected 20, 40, or 80 MHz",
992        )),
993    }
994}
995
996#[cfg(target_arch = "wasm32")]
997fn optional_u8(value: i32, name: &str) -> Result<Option<u8>, JsValue> {
998    if value < 0 {
999        return Ok(None);
1000    }
1001    u8::try_from(value)
1002        .map(Some)
1003        .map_err(|_| JsValue::from_str(&format!("{name} is outside 0..255")))
1004}
1005
1006#[cfg(target_arch = "wasm32")]
1007fn optional_u16(value: i32, name: &str) -> Result<Option<u16>, JsValue> {
1008    if value < 0 {
1009        return Ok(None);
1010    }
1011    u16::try_from(value)
1012        .map(Some)
1013        .map_err(|_| JsValue::from_str(&format!("{name} is outside 0..65535")))
1014}
1015
1016#[cfg(target_arch = "wasm32")]
1017fn optional_usize(value: i32, name: &str) -> Result<Option<usize>, JsValue> {
1018    if value < 0 {
1019        return Ok(None);
1020    }
1021    usize::try_from(value)
1022        .map(Some)
1023        .map_err(|_| JsValue::from_str(&format!("{name} is invalid")))
1024}
1025
1026#[cfg(target_arch = "wasm32")]
1027fn init_report_json(report: &InitReport) -> String {
1028    let status = match report.status {
1029        InitStatus::AlreadyRunning => "already_running",
1030        InitStatus::Initialized => "initialized",
1031    };
1032    format!(
1033        r#"{{"chip":"{}","rfPaths":{},"cutVersion":{},"status":"{}","firmwareDownloaded":{}}}"#,
1034        report.chip.family.name(),
1035        report.chip.total_rf_paths(),
1036        report.chip.cut_version,
1037        status,
1038        report.firmware_downloaded
1039    )
1040}
1041
1042#[cfg(target_arch = "wasm32")]
1043fn driver_error(err: impl std::fmt::Display) -> JsValue {
1044    JsValue::from_str(&err.to_string())
1045}
1046
1047fn video_frames_from_events(events: Vec<PipelineEvent>) -> Array {
1048    let frames = Array::new();
1049    append_video_frames(&frames, events);
1050    frames
1051}
1052
1053fn append_video_frames(frames: &Array, events: Vec<PipelineEvent>) {
1054    for event in events {
1055        if let PipelineEvent::VideoFrame(frame) = event {
1056            frames.push(&Uint8Array::from(frame.data.as_slice()));
1057        }
1058    }
1059}
1060
1061fn append_video_frame_objects(frames: &Array, events: Vec<PipelineEvent>) -> Result<(), JsValue> {
1062    for event in events {
1063        if let PipelineEvent::VideoFrame(frame) = event {
1064            frames.push(&video_frame_object(frame)?.into());
1065        }
1066    }
1067    Ok(())
1068}
1069
1070fn append_payload_objects(
1071    payloads: &Array,
1072    events: Vec<PayloadPipelineEvent>,
1073    channel_id: ChannelId,
1074    payload_count: &mut usize,
1075    payload_bytes: &mut usize,
1076) -> Result<(), JsValue> {
1077    for event in events {
1078        if let PayloadPipelineEvent::Payload(payload) = event {
1079            *payload_count += 1;
1080            *payload_bytes += payload.data.len();
1081            payloads.push(&raw_payload_object(payload, channel_id)?.into());
1082        }
1083    }
1084    Ok(())
1085}
1086
1087fn raw_payload_object(
1088    payload: openipc_core::RecoveredPayload,
1089    channel_id: ChannelId,
1090) -> Result<Object, JsValue> {
1091    let object = Object::new();
1092    Reflect::set(
1093        &object,
1094        &JsValue::from_str("data"),
1095        &Uint8Array::from(payload.data.as_slice()),
1096    )?;
1097    Reflect::set(
1098        &object,
1099        &JsValue::from_str("packetSeq"),
1100        &JsValue::from_str(&payload.packet_seq.to_string()),
1101    )?;
1102    set_number(&object, "channelId", channel_id.raw() as f64)?;
1103    Ok(object)
1104}
1105
1106fn accept_rx_packet(attrib: openipc_core::realtek::RxPacketAttrib, keep_corrupted: bool) -> bool {
1107    attrib.pkt_rpt_type == RxPacketType::NormalRx
1108        && (keep_corrupted || (!attrib.crc_err && !attrib.icv_err))
1109}
1110
1111fn video_frame_object(frame: DepacketizedFrame) -> Result<Object, JsValue> {
1112    let object = Object::new();
1113    let codec_string = codec_string(&frame);
1114    Reflect::set(
1115        &object,
1116        &JsValue::from_str("data"),
1117        &Uint8Array::from(frame.data.as_slice()),
1118    )?;
1119    Reflect::set(
1120        &object,
1121        &JsValue::from_str("codec"),
1122        &JsValue::from_str(codec_name(frame.codec)),
1123    )?;
1124    Reflect::set(
1125        &object,
1126        &JsValue::from_str("codecString"),
1127        &JsValue::from_str(&codec_string),
1128    )?;
1129    Reflect::set(
1130        &object,
1131        &JsValue::from_str("isKeyFrame"),
1132        &JsValue::from_bool(frame.is_keyframe),
1133    )?;
1134    Reflect::set(
1135        &object,
1136        &JsValue::from_str("timestamp"),
1137        &JsValue::from_f64(f64::from(frame.timestamp)),
1138    )?;
1139    Ok(object)
1140}
1141
1142fn codec_name(codec: Codec) -> &'static str {
1143    match codec {
1144        Codec::H264 => "h264",
1145        Codec::H265 => "h265",
1146    }
1147}
1148
1149fn codec_string(frame: &DepacketizedFrame) -> String {
1150    match frame.codec {
1151        Codec::H264 => h264_codec_string(&frame.data).unwrap_or_else(|| "avc1.42E01E".to_owned()),
1152        Codec::H265 => "hev1.1.6.L93.B0".to_owned(),
1153    }
1154}
1155
1156fn h264_codec_string(frame: &[u8]) -> Option<String> {
1157    for unit in annex_b_units(frame) {
1158        let nalu = &frame[unit.start..unit.end];
1159        if nalu.len() >= 4 && nalu[0] & 0x1f == 7 {
1160            return Some(format!(
1161                "avc1.{}{}{}",
1162                hex_byte(nalu[1]),
1163                hex_byte(nalu[2]),
1164                hex_byte(nalu[3])
1165            ));
1166        }
1167    }
1168    None
1169}
1170
1171#[derive(Debug, Clone, Copy)]
1172struct AnnexBUnit {
1173    start: usize,
1174    end: usize,
1175}
1176
1177fn annex_b_units(frame: &[u8]) -> Vec<AnnexBUnit> {
1178    let mut starts = Vec::new();
1179    let mut index = 0;
1180    while index + 3 < frame.len() {
1181        let len = start_code_len(frame, index);
1182        if len > 0 {
1183            starts.push(index);
1184            index += len;
1185        } else {
1186            index += 1;
1187        }
1188    }
1189    if starts.is_empty() && !frame.is_empty() {
1190        return vec![AnnexBUnit {
1191            start: 0,
1192            end: frame.len(),
1193        }];
1194    }
1195    starts
1196        .iter()
1197        .enumerate()
1198        .map(|(index, start)| AnnexBUnit {
1199            start: start + start_code_len(frame, *start),
1200            end: starts.get(index + 1).copied().unwrap_or(frame.len()),
1201        })
1202        .collect()
1203}
1204
1205fn start_code_len(frame: &[u8], offset: usize) -> usize {
1206    if frame.get(offset) != Some(&0) || frame.get(offset + 1) != Some(&0) {
1207        return 0;
1208    }
1209    if frame.get(offset + 2) == Some(&1) {
1210        return 3;
1211    }
1212    if frame.get(offset + 2) == Some(&0) && frame.get(offset + 3) == Some(&1) {
1213        return 4;
1214    }
1215    0
1216}
1217
1218fn hex_byte(value: u8) -> String {
1219    format!("{value:02X}")
1220}
1221
1222fn set_number(object: &Object, key: &str, value: f64) -> Result<(), JsValue> {
1223    Reflect::set(object, &JsValue::from_str(key), &JsValue::from_f64(value))?;
1224    Ok(())
1225}
1226
1227fn now_ms() -> f64 {
1228    #[cfg(target_arch = "wasm32")]
1229    {
1230        web_sys::window()
1231            .and_then(|window| window.performance())
1232            .map(|performance| performance.now())
1233            .unwrap_or_else(js_sys::Date::now)
1234    }
1235    #[cfg(not(target_arch = "wasm32"))]
1236    {
1237        0.0
1238    }
1239}
1240
1241fn elapsed_ms(start_ms: f64) -> f64 {
1242    let elapsed = now_ms() - start_ms;
1243    if elapsed.is_finite() && elapsed >= 0.0 {
1244        elapsed
1245    } else {
1246        0.0
1247    }
1248}
1249
1250fn openipc_receiver_with_keypair_and_mavlink_channel_inner(
1251    channel_id: u32,
1252    mavlink_channel_id: u32,
1253    keypair: WfbKeypair,
1254    minimum_epoch: u64,
1255) -> Result<OpenIpcReceiver, JsValue> {
1256    let pipeline = ReceiverPipeline::with_keypair(
1257        ChannelId::new(channel_id),
1258        FrameLayout::WithFcs,
1259        keypair,
1260        minimum_epoch,
1261    )
1262    .map_err(|err| JsValue::from_str(&format!("invalid encrypted receiver config: {err}")))?;
1263    let mavlink_pipeline = PayloadPipeline::with_keypair(
1264        ChannelId::new(mavlink_channel_id),
1265        FrameLayout::WithFcs,
1266        keypair,
1267        minimum_epoch,
1268    )
1269    .map_err(|err| JsValue::from_str(&format!("invalid MAVLink receiver config: {err}")))?;
1270    Ok(OpenIpcReceiver {
1271        pipeline,
1272        mavlink_pipeline: Some(mavlink_pipeline),
1273    })
1274}
1275
1276fn counters_json(counters: FecCounters) -> String {
1277    format!(
1278        r#"{{"totalPackets":{},"recoveredPackets":{},"lostPackets":{},"badPackets":{}}}"#,
1279        counters.total_packets,
1280        counters.recovered_packets,
1281        counters.lost_packets,
1282        counters.bad_packets
1283    )
1284}
1285
1286fn escape_json_str(value: &str) -> String {
1287    let mut out = String::with_capacity(value.len());
1288    for ch in value.chars() {
1289        match ch {
1290            '"' => out.push_str("\\\""),
1291            '\\' => out.push_str("\\\\"),
1292            '\n' => out.push_str("\\n"),
1293            '\r' => out.push_str("\\r"),
1294            '\t' => out.push_str("\\t"),
1295            _ => out.push(ch),
1296        }
1297    }
1298    out
1299}
1300
1301fn ms_from_js(now_ms: f64) -> u64 {
1302    if now_ms.is_finite() && now_ms > 0.0 {
1303        now_ms.min(u64::MAX as f64) as u64
1304    } else {
1305        0
1306    }
1307}
1308
1309#[wasm_bindgen(js_name = supportedUsbFilters)]
1310pub fn supported_usb_filters() -> String {
1311    // Kept as JSON to avoid forcing web-sys types into the Rust API.
1312    r#"[{"vendorId":3034,"productId":34834},{"vendorId":3034,"productId":2065},{"vendorId":3034,"productId":43025},{"vendorId":3034,"productId":47121},{"vendorId":3034,"productId":34835},{"vendorId":9047,"productId":288}]"#.to_owned()
1313}
1314
1315#[cfg(target_arch = "wasm32")]
1316#[wasm_bindgen(js_name = listAuthorizedUsbDevices)]
1317pub async fn list_authorized_usb_devices() -> Result<Array, JsValue> {
1318    let devices = nusb::list_devices()
1319        .await
1320        .map_err(|err| JsValue::from_str(&format!("nusb list_devices failed: {err}")))?;
1321
1322    let out = Array::new();
1323    for device in devices {
1324        let obj = Object::new();
1325        Reflect::set(
1326            &obj,
1327            &JsValue::from_str("vendorId"),
1328            &JsValue::from_f64(device.vendor_id() as f64),
1329        )?;
1330        Reflect::set(
1331            &obj,
1332            &JsValue::from_str("productId"),
1333            &JsValue::from_f64(device.product_id() as f64),
1334        )?;
1335        if let Some(product) = device.product_string() {
1336            Reflect::set(
1337                &obj,
1338                &JsValue::from_str("product"),
1339                &JsValue::from_str(product),
1340            )?;
1341        }
1342        if let Some(manufacturer) = device.manufacturer_string() {
1343            Reflect::set(
1344                &obj,
1345                &JsValue::from_str("manufacturer"),
1346                &JsValue::from_str(manufacturer),
1347            )?;
1348        }
1349        out.push(&obj);
1350    }
1351    Ok(out)
1352}
1353
1354fn parse_hex_u64(input: &str) -> Result<u64, JsValue> {
1355    let trimmed = input.trim();
1356    let hex = trimmed
1357        .strip_prefix("0x")
1358        .or_else(|| trimmed.strip_prefix("0X"))
1359        .unwrap_or(trimmed);
1360    u64::from_str_radix(hex, 16)
1361        .map_err(|err| JsValue::from_str(&format!("invalid nonce hex: {err}")))
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367
1368    #[test]
1369    fn h264_codec_string_comes_from_annex_b_sps() {
1370        let frame = [0, 0, 0, 1, 0x67, 0x64, 0x00, 0x1f, 0xac, 0xd9];
1371        assert_eq!(h264_codec_string(&frame).as_deref(), Some("avc1.64001F"));
1372    }
1373}