Skip to main content

openipc_web/
adaptive.rs

1use js_sys::{Array, Uint8Array};
2use openipc_core::ieee80211::WifiFrame;
3use openipc_core::realtek::{parse_rx_aggregate, RxPacketType};
4use openipc_core::{
5    AdaptiveLinkSender, ChannelId, FecCounters, FrameLayout, RadioPort, WfbTxKeypair,
6};
7use wasm_bindgen::prelude::*;
8
9use crate::js::{counters_json, escape_json_str, ms_from_js};
10use crate::receiver::OpenIpcReceiver;
11#[cfg(target_arch = "wasm32")]
12use crate::webusb::WebUsbRealtekDevice;
13
14#[wasm_bindgen]
15/// Browser/WASM adaptive-link feedback sender.
16///
17/// The app records RX quality and FEC counters, then calls `tick()` or
18/// `tickAndSend()` to produce/send encrypted WFB feedback packets.
19pub struct OpenIpcAdaptiveLink {
20    sender: AdaptiveLinkSender,
21    last_counters: FecCounters,
22    rx_channel_id: ChannelId,
23}
24
25#[wasm_bindgen]
26impl OpenIpcAdaptiveLink {
27    #[wasm_bindgen(constructor)]
28    /// Create a new adaptive-link sender for a link id and WFB TX keypair.
29    pub fn new(
30        link_id: u32,
31        keypair: &[u8],
32        epoch: u64,
33        fec_k: usize,
34        fec_n: usize,
35    ) -> Result<OpenIpcAdaptiveLink, JsValue> {
36        let keypair = WfbTxKeypair::from_bytes(keypair)
37            .map_err(|err| JsValue::from_str(&format!("invalid adaptive-link keypair: {err}")))?;
38        let sender = AdaptiveLinkSender::new(link_id, keypair, epoch, fec_k, fec_n)
39            .map_err(|err| JsValue::from_str(&format!("invalid adaptive-link config: {err}")))?;
40        Ok(Self {
41            sender,
42            last_counters: FecCounters::default(),
43            rx_channel_id: ChannelId::from_link_port(link_id, RadioPort::Video),
44        })
45    }
46
47    #[wasm_bindgen(js_name = recordRx)]
48    /// Record one pair of RSSI/SNR samples for link-quality estimation.
49    pub fn record_rx(&mut self, now_ms: f64, rssi0: u8, rssi1: u8, snr0: i8, snr1: i8) {
50        self.sender
51            .link_mut()
52            .record_rx(ms_from_js(now_ms), rssi0, rssi1, snr0, snr1);
53    }
54
55    #[wasm_bindgen(js_name = recordRxTransfer)]
56    /// Parse one RX transfer and record RSSI/SNR for matching video frames.
57    pub fn record_rx_transfer(&mut self, transfer: &[u8], now_ms: f64) -> Result<(), JsValue> {
58        let packets = parse_rx_aggregate(transfer)
59            .map_err(|err| JsValue::from_str(&format!("Realtek RX aggregate rejected: {err}")))?;
60        let now_ms = ms_from_js(now_ms);
61        for packet in packets {
62            if packet.attrib.crc_err
63                || packet.attrib.icv_err
64                || packet.attrib.pkt_rpt_type != RxPacketType::NormalRx
65            {
66                continue;
67            }
68            if !WifiFrame::parse(packet.data, FrameLayout::WithFcs)
69                .map(|frame| frame.matches_channel_id(self.rx_channel_id))
70                .unwrap_or(false)
71            {
72                continue;
73            }
74            self.sender
75                .record_rx_paths(now_ms, packet.attrib.rssi, packet.attrib.snr);
76        }
77        Ok(())
78    }
79
80    #[wasm_bindgen(js_name = recordReceiverCounters)]
81    /// Record FEC counter deltas from an [`OpenIpcReceiver`].
82    pub fn record_receiver_counters(&mut self, receiver: &OpenIpcReceiver, now_ms: f64) {
83        self.record_counter_delta(ms_from_js(now_ms), receiver.video_fec_counters());
84    }
85
86    #[wasm_bindgen(js_name = recordFec)]
87    /// Record explicit FEC totals for the current time window.
88    pub fn record_fec(&mut self, now_ms: f64, total: u32, recovered: u32, lost: u32) {
89        self.sender
90            .record_fec(ms_from_js(now_ms), total, recovered, lost);
91    }
92
93    #[wasm_bindgen(js_name = requestKeyframe)]
94    /// Force keyframe-request messages in upcoming feedback packets.
95    pub fn request_keyframe(&mut self) {
96        self.sender.link_mut().request_keyframe();
97    }
98
99    #[wasm_bindgen(js_name = setKeyframeRequestMessages)]
100    /// Configure how many feedback packets carry a keyframe request.
101    pub fn set_keyframe_request_messages(&mut self, messages: u32) {
102        self.sender
103            .link_mut()
104            .set_keyframe_request_messages(messages);
105    }
106
107    #[wasm_bindgen(js_name = setVideoStartIdleMs)]
108    /// Configure how long a quiet video stream is considered idle.
109    pub fn set_video_start_idle_ms(&mut self, idle_ms: u32) {
110        self.sender
111            .link_mut()
112            .set_video_start_idle_ms(idle_ms as u64);
113    }
114
115    #[wasm_bindgen(js_name = tick)]
116    /// Return feedback frames that should be sent at `now_ms`.
117    pub fn tick(&mut self, now_ms: f64) -> Result<Array, JsValue> {
118        let frames = self
119            .sender
120            .tick(ms_from_js(now_ms))
121            .map_err(|err| JsValue::from_str(&format!("adaptive-link tick failed: {err}")))?;
122        let out = Array::new();
123        for frame in frames {
124            out.push(&Uint8Array::from(frame.as_slice()));
125        }
126        Ok(out)
127    }
128
129    #[wasm_bindgen(js_name = counters)]
130    /// Return the last recorded FEC counters as JSON.
131    pub fn counters(&self) -> String {
132        counters_json(self.last_counters)
133    }
134
135    #[wasm_bindgen(js_name = quality)]
136    /// Return the current link-quality report as JSON.
137    pub fn quality(&mut self, now_ms: f64) -> String {
138        let quality = self.sender.link_mut().quality(ms_from_js(now_ms));
139        format!(
140            r#"{{"lostLastSecond":{},"recoveredLastSecond":{},"totalLastSecond":{},"rssi":[{},{}],"snr":[{},{}],"linkScore":[{},{}],"idrCode":"{}"}}"#,
141            quality.lost_last_second,
142            quality.recovered_last_second,
143            quality.total_last_second,
144            quality.rssi[0],
145            quality.rssi[1],
146            quality.snr[0],
147            quality.snr[1],
148            quality.link_score[0],
149            quality.link_score[1],
150            escape_json_str(&quality.idr_code),
151        )
152    }
153
154    fn record_counter_delta(&mut self, now_ms: u64, counters: FecCounters) {
155        let total = counters
156            .total_packets
157            .saturating_sub(self.last_counters.total_packets);
158        let recovered = counters
159            .recovered_packets
160            .saturating_sub(self.last_counters.recovered_packets);
161        let lost = counters
162            .lost_packets
163            .saturating_sub(self.last_counters.lost_packets);
164        self.last_counters = counters;
165        self.sender.record_fec(
166            now_ms,
167            total.min(u32::MAX as u64) as u32,
168            recovered.min(u32::MAX as u64) as u32,
169            lost.min(u32::MAX as u64) as u32,
170        );
171    }
172}
173
174#[cfg(target_arch = "wasm32")]
175#[wasm_bindgen]
176impl OpenIpcAdaptiveLink {
177    #[wasm_bindgen(js_name = tickAndSend)]
178    /// Produce due feedback frames and send them through a WebUSB Realtek device.
179    pub async fn tick_and_send(
180        &mut self,
181        device: &WebUsbRealtekDevice,
182        now_ms: f64,
183        current_channel: u8,
184    ) -> Result<usize, JsValue> {
185        let frames = self
186            .sender
187            .tick(ms_from_js(now_ms))
188            .map_err(|err| JsValue::from_str(&format!("adaptive-link tick failed: {err}")))?;
189        let count = frames.len();
190        for frame in frames {
191            device.send_packet(&frame, current_channel).await?;
192        }
193        Ok(count)
194    }
195}