Skip to main content

mx_remote/runtime/
osd.rs

1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! Sending an overlay sync, and answering the units that take it.
5//!
6//! A sync announces what it copies and how many settings it has. Each unit
7//! fetches the logo images, then asks the source for the settings it lacks,
8//! once a second until it has them all, and takes none it did not ask for.
9//! So the settings go out only in answer to a request, a few at a time: a
10//! unit drops one that arrives while it is still writing earlier ones, and
11//! asks for it again.
12//!
13//! While this client holds a sync it also announces itself as the mesh's
14//! overlay sync manager. A unit that wakes from power save, or that is told
15//! to pull, then asks this client for a new sync instead of the controller.
16
17use std::collections::VecDeque;
18use std::sync::Arc;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21use crate::types::{
22    OsdSyncContent, OsdSyncEntry, OsdSyncImage, OsdSyncRequest, OsdSyncSlot, OsdSyncValue,
23    OSD_SYNC_FONT_MAX, OSD_SYNC_IMAGE_HEIGHT_MAX, OSD_SYNC_IMAGE_MAX, OSD_SYNC_IMAGE_WIDTH_MAX,
24    OSD_SYNC_PERCENT_MAX, OSD_SYNC_RECORDS_MAX, OSD_SYNC_SLOTS, OSD_SYNC_TEXT_MAX,
25};
26use crate::wire::{
27    build_osd_setting, build_osd_sync, crc32, op, Addressee, DeviceFeature, DeviceUid, OsdSyncWhat,
28};
29
30use super::control::ControlError;
31use super::logo_server::{Images, LogoServer};
32use super::{lock, Remote, Shared, SHUTDOWN_POLL};
33
34/// How many syncs of the held content a request may still name. Each unit
35/// that asks for a new sync is sent one of its own; a request naming one
36/// that has aged out is answered with another.
37const SEQS_KEPT: usize = 64;
38
39/// How many settings go out together, and the gap after them, which a unit
40/// keeps up with.
41const SETTINGS_PER_PASS: usize = 4;
42const PASS_GAP: Duration = Duration::from_millis(14);
43
44/// The bytes every PNG opens with, and where its header gives the size.
45const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
46const PNG_IHDR_AT: usize = 12;
47
48/// What this client sends and answers.
49pub(super) struct OsdSender {
50    held: Option<Held>,
51    next_seq: u16,
52    queue: VecDeque<Pending>,
53    server: Option<LogoServer>,
54}
55
56/// The sync this client holds.
57struct Held {
58    /// What it copies, with [`OsdSyncWhat::LOGO_SLOTS`] when it has images.
59    what: OsdSyncWhat,
60    entries: Arc<[OsdSyncEntry]>,
61    slots: [OsdSyncSlot; OSD_SYNC_SLOTS],
62    http_port: u16,
63    /// The syncs sent of it, oldest first.
64    seqs: VecDeque<u16>,
65}
66
67/// A setting a unit asked for, not yet sent.
68#[derive(Clone, Copy, PartialEq, Eq)]
69struct Pending {
70    unit: DeviceUid,
71    seq: u16,
72    index: u16,
73}
74
75impl OsdSender {
76    /// A sender numbering from the wall clock, so a restarted client does not
77    /// reuse a recent number: requests and reports name a sync by its number,
78    /// and a unit drops a sync repeating the last one's within two seconds.
79    pub(super) fn new() -> Self {
80        let ms = SystemTime::now()
81            .duration_since(UNIX_EPOCH)
82            .map_or(0, |d| d.as_millis());
83        Self {
84            held: None,
85            next_seq: (ms as u16).max(1),
86            queue: VecDeque::new(),
87            server: None,
88        }
89    }
90
91    /// Whether a sync is held, which is when this client is the manager.
92    pub(super) fn holds(&self) -> bool {
93        self.held.is_some()
94    }
95
96    /// Numbers a new sync of the held content, copying `what`, which is
97    /// some of what the content copies. Zero means "no sync", so the count
98    /// skips it.
99    fn sync_frame(&mut self, target: DeviceUid, what: OsdSyncWhat) -> Option<(u16, Vec<u8>)> {
100        let seq = self.next_seq;
101        self.next_seq = seq.wrapping_add(1).max(1);
102        let held = self.held.as_mut()?;
103        if held.seqs.len() == SEQS_KEPT {
104            held.seqs.pop_front();
105        }
106        held.seqs.push_back(seq);
107        let records = held.entries.len() as u16;
108        let frame = build_osd_sync(seq, target, what, held.http_port, records, &held.slots);
109        Some((seq, frame))
110    }
111
112    /// Replaces what is still to be sent to `unit` with the settings it
113    /// lacks of sync `seq`.
114    fn queue_missing(&mut self, unit: DeviceUid, request: &OsdSyncRequest) {
115        let Some(held) = &self.held else {
116            return;
117        };
118        self.queue.retain(|p| p.unit != unit);
119        for index in 0..held.entries.len() as u16 {
120            if request.is_missing(index) {
121                self.queue.push_back(Pending {
122                    unit,
123                    seq: request.seq,
124                    index,
125                });
126            }
127        }
128    }
129
130    /// The next few settings to send, as their unit and frame.
131    ///
132    /// The units take turns: a unit's other settings go behind everyone
133    /// else's each time one of its own is taken, so one asking for many does
134    /// not hold the rest past the time a unit allows a sync.
135    fn next_pass(&mut self) -> Vec<(DeviceUid, Vec<u8>)> {
136        let mut pass = Vec::new();
137        while pass.len() < SETTINGS_PER_PASS {
138            let Some(p) = self.queue.pop_front() else {
139                break;
140            };
141            let (theirs, others): (VecDeque<_>, VecDeque<_>) =
142                self.queue.drain(..).partition(|q| q.unit == p.unit);
143            self.queue = others;
144            self.queue.extend(theirs);
145            let Some(held) = &self.held else {
146                break;
147            };
148            // A request for a sync since replaced, or aged out, is answered
149            // no further: the unit asks again and gets a new sync.
150            let Some(entry) = held.entries.get(usize::from(p.index)) else {
151                continue;
152            };
153            if !held.seqs.contains(&p.seq) {
154                continue;
155            }
156            let records = held.entries.len() as u16;
157            pass.push((
158                p.unit,
159                build_osd_setting(p.seq, p.unit, p.index, records, entry),
160            ));
161        }
162        pass
163    }
164}
165
166impl Shared {
167    fn addressee(&self, unit: DeviceUid) -> Option<Addressee> {
168        self.read(|state| state.device(unit).map(|d| Addressee::device(d)))
169    }
170
171    /// Answers the overlay sync requests a frame raised.
172    ///
173    /// One asking for a new sync is sent one of its own, copying what it
174    /// asks for of what this client holds. One naming a sync of the held
175    /// content has the settings it lacks queued, in place of what was still
176    /// queued for it. One naming any other sync of this client's is sent a
177    /// new sync the same way, since the unit can take no other.
178    pub(super) fn answer_osd_requests(&self, requests: Vec<OsdSyncRequest>) {
179        for request in requests {
180            let unit = request.target;
181            let Some(to) = self.addressee(unit) else {
182                continue;
183            };
184            let sync = {
185                let mut osd = lock(&self.osd);
186                let Some(held) = &osd.held else {
187                    return;
188                };
189                let (held_what, current) = (held.what, held.seqs.contains(&request.seq));
190                if request.seq != 0 && current {
191                    osd.queue_missing(unit, &request);
192                    self.osd_queued.notify_one();
193                    None
194                } else {
195                    // A new sync copies what the request asks for, of what
196                    // is held; a request asking for none of it gets none.
197                    let what = request.what & held_what;
198                    if what.is_empty() {
199                        continue;
200                    }
201                    osd.sync_frame(unit, what)
202                }
203            };
204            if let Some((_, frame)) = sync {
205                let _ = self.send(&to, op::V2IP_OSD_SYNC, &frame);
206            }
207        }
208    }
209
210    /// Sends the next few settings units asked for, returning how many.
211    pub(super) fn osd_pass(&self) -> usize {
212        let pass = lock(&self.osd).next_pass();
213        for (unit, frame) in &pass {
214            if let Some(to) = self.addressee(*unit) {
215                let _ = self.send(&to, op::V2IP_OSD_SYNC, frame);
216            }
217        }
218        pass.len()
219    }
220
221    /// Sends settings as units ask for them, until the client is closing.
222    pub(super) fn osd_loop(&self) {
223        while !self.closing.load(std::sync::atomic::Ordering::SeqCst) {
224            if self.osd_pass() > 0 {
225                std::thread::sleep(PASS_GAP);
226                continue;
227            }
228            let osd = lock(&self.osd);
229            if osd.queue.is_empty() {
230                let _ = self.osd_queued.wait_timeout(osd, SHUTDOWN_POLL);
231            }
232        }
233    }
234
235    /// Drops the held sync and stops serving its images.
236    pub(super) fn drop_osd_sync(&self) -> bool {
237        let (held, server) = {
238            let mut osd = lock(&self.osd);
239            (osd.held.take().is_some(), osd.server.take())
240        };
241        // Joined outside the lock: the server thread never takes it, but a
242        // join is no place to hold one.
243        drop(server);
244        held
245    }
246}
247
248impl Remote {
249    /// Has every unit copy `content`, and keeps it in place, returning the
250    /// sync's number.
251    ///
252    /// Units take a sync from the mesh controller or a management
253    /// application, which this client announces itself as. Each unit
254    /// fetches the images from a server this client runs, then asks for the
255    /// settings, which this client answers until the content is replaced or
256    /// [`Remote::clear_osd_sync`]. Meanwhile it announces itself as the
257    /// mesh's overlay sync manager, so a unit waking from power save takes
258    /// this content too, rather than the controller's. The controller can
259    /// still send its own over it; the last sync a unit took is what it
260    /// shows. Each unit's
261    /// [`Remote::osd_sync_report`] says how it went.
262    ///
263    /// Refused for content copying nothing, with more than
264    /// [`OSD_SYNC_RECORDS_MAX`] settings, a caption font over
265    /// [`OSD_SYNC_FONT_MAX`] or text over [`OSD_SYNC_TEXT_MAX`] bytes, a logo
266    /// other than -1 to 4, or an image that is not a PNG within
267    /// [`OSD_SYNC_IMAGE_MAX`] bytes and [`OSD_SYNC_IMAGE_WIDTH_MAX`] by
268    /// [`OSD_SYNC_IMAGE_HEIGHT_MAX`] pixels, shown at over
269    /// [`OSD_SYNC_PERCENT_MAX`] percent. Refused content leaves the held sync
270    /// as it was; content that fails to go out leaves none held.
271    pub fn send_osd_sync(&self, content: OsdSyncContent) -> Result<u16, ControlError> {
272        validate(&content)?;
273        self.hold_osd_sync(content)
274            .inspect_err(|_| self.clear_osd_sync())
275    }
276
277    fn hold_osd_sync(&self, content: OsdSyncContent) -> Result<u16, ControlError> {
278        let images = content.images.as_ref();
279        let mut what =
280            OsdSyncWhat::from_bits(content.what.bits() & !OsdSyncWhat::LOGO_SLOTS.bits());
281        if images.is_some() {
282            what |= OsdSyncWhat::LOGO_SLOTS;
283        }
284        let slots = images.map_or_else(Default::default, |images| {
285            core::array::from_fn(|i| OsdSyncSlot {
286                bytes: images[i].png.len() as u32,
287                crc32: crc32(&images[i].png),
288                percent: images[i].percent,
289            })
290        });
291        let (was_held, sync) = {
292            let mut osd = lock(&self.shared.osd);
293            let http_port = match images {
294                Some(images) => serve(&mut osd.server, self.shared.logo_port, images)?,
295                None => 0,
296            };
297            let was_held = osd.held.is_some();
298            osd.held = Some(Held {
299                what,
300                entries: content.entries.into(),
301                slots,
302                http_port,
303                seqs: VecDeque::new(),
304            });
305            (was_held, osd.sync_frame(DeviceUid::ZERO, what))
306        };
307        let (seq, frame) = sync.ok_or(ControlError::InvalidRequest("no overlay sync is held"))?;
308        // Announced first, so the controller has stood back by the time a
309        // unit deferring this sync wakes and asks for it.
310        if !was_held {
311            self.shared.announce();
312        }
313        self.shared
314            .send(&Addressee::Broadcast, op::V2IP_OSD_SYNC, &frame)?;
315        Ok(seq)
316    }
317
318    /// Has one unit copy the sync this client holds again, returning the
319    /// new sync's number. As [`Remote::send_osd_sync`].
320    ///
321    /// Refused while no sync is held, and for a unit that does not announce
322    /// [`OSD_SYNC`](DeviceFeature::OSD_SYNC), as one that does not take
323    /// overlay syncs.
324    pub fn resend_osd_sync(&self, device: DeviceUid) -> Result<u16, ControlError> {
325        let to = self
326            .shared
327            .addressee(device)
328            .ok_or(ControlError::UnknownDevice(device))?;
329        let takes = self.shared.read(|state| {
330            state
331                .device(device)
332                .is_some_and(|d| d.hello.features.has(DeviceFeature::OSD_SYNC))
333        });
334        if !takes {
335            return Err(ControlError::Unsupported("the unit takes no overlay sync"));
336        }
337        let (seq, frame) = {
338            let mut osd = lock(&self.shared.osd);
339            let what = osd.held.as_ref().map(|h| h.what);
340            what.and_then(|what| osd.sync_frame(device, what))
341                .ok_or(ControlError::InvalidRequest("no overlay sync is held"))?
342        };
343        self.shared.send(&to, op::V2IP_OSD_SYNC, &frame)?;
344        Ok(seq)
345    }
346
347    /// Stops holding the sync this client sent, and hands the overlay sync
348    /// manager's part back to the controller.
349    ///
350    /// Units keep what they took. One still taking it fails, as its
351    /// requests go unanswered.
352    pub fn clear_osd_sync(&self) {
353        if self.shared.drop_osd_sync() {
354            self.shared.announce();
355        }
356    }
357}
358
359/// Starts the image server if it is not running, and serves `images`,
360/// returning its port.
361fn serve(
362    server: &mut Option<LogoServer>,
363    port: Option<u16>,
364    images: &[OsdSyncImage; OSD_SYNC_SLOTS],
365) -> Result<u16, ControlError> {
366    let running = match server.take() {
367        Some(running) => running,
368        None => LogoServer::start(port.unwrap_or(0))
369            .map_err(|e| ControlError::Send(crate::wire::SendError::Io(e)))?,
370    };
371    let images: Images = core::array::from_fn(|i| Arc::from(images[i].png.as_slice()));
372    running.set_images(images);
373    let port = running.port();
374    *server = Some(running);
375    Ok(port)
376}
377
378fn validate(content: &OsdSyncContent) -> Result<(), ControlError> {
379    let invalid = |what| Err(ControlError::InvalidRequest(what));
380    let copies = OsdSyncWhat::from_bits(content.what.bits() & !OsdSyncWhat::LOGO_SLOTS.bits());
381    if copies.is_empty() && content.images.is_none() {
382        return invalid("an overlay sync copying something");
383    }
384    if content.entries.len() > OSD_SYNC_RECORDS_MAX {
385        return invalid("an overlay sync of at most 128 settings");
386    }
387    for entry in &content.entries {
388        match &entry.value {
389            OsdSyncValue::Caption(c) if c.font.len() > OSD_SYNC_FONT_MAX => {
390                return invalid("a caption font of at most 31 bytes");
391            }
392            OsdSyncValue::Caption(c) if c.text.len() > OSD_SYNC_TEXT_MAX => {
393                return invalid("a caption text of at most 360 bytes");
394            }
395            OsdSyncValue::Logo(l) if !(-1..=4).contains(&l.logo) => {
396                return invalid("a logo of -1 to 4");
397            }
398            _ => {}
399        }
400    }
401    for image in content.images.iter().flatten() {
402        if image.percent > OSD_SYNC_PERCENT_MAX {
403            return invalid("a logo size of 0 to 100 percent");
404        }
405        if !image.png.is_empty() && !fits(&image.png) {
406            return invalid("a PNG of at most 512 KB and 1920 by 1080 pixels");
407        }
408    }
409    Ok(())
410}
411
412/// Whether `png` is a PNG a unit takes, by its size and the header's.
413fn fits(png: &[u8]) -> bool {
414    let be32 = |at: usize| {
415        png.get(at..at + 4)
416            .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
417    };
418    let (Some(width), Some(height)) = (be32(PNG_IHDR_AT + 4), be32(PNG_IHDR_AT + 8)) else {
419        return false;
420    };
421    png.len() <= OSD_SYNC_IMAGE_MAX
422        && png.starts_with(PNG_SIGNATURE)
423        && png.get(PNG_IHDR_AT..PNG_IHDR_AT + 4) == Some(b"IHDR")
424        && (1..=OSD_SYNC_IMAGE_WIDTH_MAX).contains(&width)
425        && (1..=OSD_SYNC_IMAGE_HEIGHT_MAX).contains(&height)
426}
427
428#[cfg(test)]
429#[path = "osd_tests.rs"]
430mod tests;