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