Skip to main content

ipp_printer_app/
printer.rs

1//! Per-printer configuration and runtime state.
2
3use std::sync::Arc;
4
5use parking_lot::RwLock;
6
7use crate::flags::PrinterReason;
8
9/// Static printer capabilities supplied by the consumer crate (typically
10/// loaded from a config file). Carries everything the framework needs to
11/// build the IPP `Get-Printer-Attributes` response.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13#[allow(missing_docs)]
14pub struct PrinterConfig {
15    /// Logical, machine-readable name: the CUPS queue name, the IPP
16    /// `printer-name`, the `/ipp/print/<name>` resource path, and the mDNS
17    /// `rp` TXT all use this. Keep it to `[a-z0-9_]` so it round-trips through
18    /// CUPS's own DNS-SD queue-name sanitiser (`cups_queue_name`), letting a
19    /// co-resident CUPS recognise its on-demand temp queue as already-served
20    /// (its lookup is case-insensitive).
21    pub name: String,
22    /// Human-readable name shown to users: the mDNS **service instance name**
23    /// (what OS print dialogs display), IPP `printer-info`, and the web UI.
24    /// May contain spaces / mixed case. Empty falls back to `make_and_model`,
25    /// then `name`. `#[serde(default)]` so older persisted state still loads.
26    #[serde(default)]
27    pub display_name: String,
28    pub driver_name: String,
29    pub make_and_model: String,
30    pub device_id: String,
31    pub device_uri: String,
32    pub dpi: i32,
33    pub printhead_width_dots: u32,
34    pub media_names: Vec<String>,
35    pub media_sizes: Vec<[i32; 2]>,
36    /// Smallest and largest custom media the printer accepts, `[x, y]` in
37    /// hundredths of a millimetre.
38    ///
39    /// When both are non-zero, `media-size-supported` gains a range entry and
40    /// the PWG `custom_min_*` / `custom_max_*` names join `media-supported` —
41    /// which is what makes a desktop print dialog offer a Custom size rather
42    /// than only the enumerated list. Leave either zeroed to advertise fixed
43    /// sizes only. `#[serde(default)]` so state written before this existed
44    /// still loads.
45    #[serde(default)]
46    pub media_size_min: [i32; 2],
47    #[serde(default)]
48    pub media_size_max: [i32; 2],
49    /// Darkness 0–100 (maps to print density).
50    pub darkness: i32,
51    /// MIME types the consumer's print callback can decode, emitted as
52    /// `document-format-supported`. Empty falls back to the framework's raster
53    /// defaults (`image/pwg-raster`, `application/vnd.cups-raster`,
54    /// `application/octet-stream`). Add `image/jpeg` etc. when the backend can
55    /// handle them.
56    #[serde(default)]
57    pub document_formats: Vec<String>,
58}
59
60impl PrinterConfig {
61    /// Build the canonical `ipp://<host>:<port>/ipp/print/<name>` URI. If
62    /// `host` is unspecified (`0.0.0.0`, `::`, empty), advertises
63    /// `localhost` so CUPS and mDNS clients get a reachable address.
64    pub fn printer_uri(&self, host: &str, port: u16) -> String {
65        let h = if host == "0.0.0.0" || host == "::" || host.is_empty() {
66            "localhost"
67        } else {
68            host
69        };
70        format!("ipp://{h}:{port}/ipp/print/{}", self.name)
71    }
72
73    /// The effective human-readable label: `display_name` if set, else
74    /// `make_and_model`, else the logical `name`. Used for the mDNS service
75    /// instance name, IPP `printer-info`, and the web UI.
76    pub fn display_label(&self) -> &str {
77        if !self.display_name.is_empty() {
78            &self.display_name
79        } else if !self.make_and_model.is_empty() {
80            &self.make_and_model
81        } else {
82            &self.name
83        }
84    }
85}
86
87/// IPP `printer-state` enum (RFC 8011 §5.4.11).
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89#[repr(u32)]
90#[allow(missing_docs)]
91pub enum IppPrinterState {
92    Idle = 3,
93    Processing = 4,
94    Stopped = 5,
95}
96
97/// Runtime printer entry in the server registry.
98#[derive(Debug, Clone)]
99#[allow(missing_docs)]
100pub struct PrinterRecord {
101    pub config: PrinterConfig,
102    pub state: IppPrinterState,
103    pub reasons: PrinterReason,
104    pub uuid: String,
105    /// Live media loaded in the device, set by the status poller. `None` until
106    /// the first successful poll — the attribute builder then falls back to the
107    /// configured default for `media-ready` / `media-col-ready`.
108    pub ready_media: Option<crate::device::ReadyMedia>,
109    /// Live remaining-supply level 0–100 from the status poller. `None` falls
110    /// back to a full static `printer-supply`.
111    pub supply_percent: Option<u8>,
112}
113
114impl PrinterRecord {
115    /// Wrap a config in a fresh record (state = `Idle`, no reasons set, new UUID).
116    pub fn new(config: PrinterConfig) -> Self {
117        Self {
118            uuid: uuid::Uuid::new_v4().to_string(),
119            state: IppPrinterState::Idle,
120            reasons: PrinterReason::empty(),
121            ready_media: None,
122            supply_percent: None,
123            config,
124        }
125    }
126
127    /// Record `media` as the loaded roll: publish it, enumerate it as
128    /// supported, and make it this printer's default.
129    ///
130    /// `media-ready` alone is not enough to make a size selectable — a print
131    /// dialog builds its list from `media-supported`, and CUPS rejects a
132    /// `media` value that is not in it. A printer reporting a roll the config
133    /// never listed would advertise a size nothing can ask for, which is what
134    /// an RFID-tagged 50x80 roll did on a T50 whose table stopped at 50x30.
135    ///
136    /// The size goes to the *front*, because index 0 is what
137    /// `media-default` / `media-col-default` report. Whatever is physically
138    /// loaded is the sensible default, and it stays that way until another
139    /// roll is loaded or the operator sets one through the media page — which
140    /// arrives here too.
141    ///
142    /// Returns whether anything changed, so a caller polling every few seconds
143    /// can persist only on a real roll swap.
144    pub fn set_ready_media(&mut self, media: crate::device::ReadyMedia) -> bool {
145        let was_default = self.config.media_names.first() == Some(&media.name);
146        if !was_default {
147            // Drop any existing entry before re-inserting at the front, so the
148            // list cannot accumulate duplicates across roll swaps. `media_sizes`
149            // is index-aligned with `media_names` but may be shorter (callers
150            // are allowed to leave the tail to the default size), so only
151            // remove a size that actually exists.
152            if let Some(i) = self
153                .config
154                .media_names
155                .iter()
156                .position(|n| *n == media.name)
157            {
158                self.config.media_names.remove(i);
159                if i < self.config.media_sizes.len() {
160                    self.config.media_sizes.remove(i);
161                }
162            }
163            self.config.media_names.insert(0, media.name.clone());
164            self.config.media_sizes.insert(0, media.size_hmm);
165        }
166        let changed =
167            !was_default || self.ready_media.as_ref().map(|m| m.size_hmm) != Some(media.size_hmm);
168        self.ready_media = Some(media);
169        changed
170    }
171}
172
173/// Borrowed view of a printer passed into [`crate::RasterDriver`] callbacks.
174///
175/// `record` is exposed for direct access; the helpers below are the
176/// commonly-needed shortcuts.
177pub struct PrinterHandle<'a> {
178    /// The underlying registry entry.
179    pub record: &'a PrinterRecord,
180}
181
182impl<'a> PrinterHandle<'a> {
183    /// Driver name from the config (matches the value supplied by
184    /// [`crate::DeviceBackend::driver_for_device`]).
185    pub fn driver_name(&self) -> &str {
186        &self.record.config.driver_name
187    }
188
189    /// Configured darkness, 0–100.
190    pub fn darkness(&self) -> i32 {
191        self.record.config.darkness
192    }
193
194    /// Printhead width in dots.
195    pub fn printhead_width_dots(&self) -> u32 {
196        self.record.config.printhead_width_dots
197    }
198}
199
200/// Shared printer registry. Cheap to clone (it's an `Arc`).
201pub type PrinterRegistry = Arc<RwLock<Vec<PrinterRecord>>>;
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    /// A persisted config written before `document_formats` existed must still
208    /// deserialize (the field is `#[serde(default)]` → empty).
209    #[test]
210    fn config_without_document_formats_loads() {
211        let json = r#"{
212            "name": "p", "driver_name": "d", "make_and_model": "m",
213            "device_id": "", "device_uri": "mock://x", "dpi": 203,
214            "printhead_width_dots": 384, "media_names": [], "media_sizes": [],
215            "darkness": 50
216        }"#;
217        let cfg: PrinterConfig = serde_json::from_str(json).expect("back-compat load");
218        assert!(cfg.document_formats.is_empty());
219    }
220
221    /// A minimal config with one enumerated size, standing in for a T50 whose
222    /// table stops short of the roll that is actually loaded.
223    fn t50_config() -> PrinterConfig {
224        PrinterConfig {
225            name: "t50".into(),
226            display_name: String::new(),
227            driver_name: "supvan_t50".into(),
228            make_and_model: "Supvan T50 Series".into(),
229            device_id: String::new(),
230            device_uri: "mock://x".into(),
231            dpi: 203,
232            printhead_width_dots: 384,
233            media_names: vec!["om_40x30mm_40x30mm".into()],
234            media_sizes: vec![[4000, 3000]],
235            media_size_min: [0, 0],
236            media_size_max: [0, 0],
237            darkness: 50,
238            document_formats: vec![],
239        }
240    }
241
242    /// A roll the device reports but the config never listed has to join
243    /// `media-supported`, not just `media-ready` — otherwise it is advertised
244    /// as loaded while remaining impossible to select. This is the RFID-tagged
245    /// 50x80 case on a T50 whose table stopped at 50x30.
246    #[test]
247    fn device_reported_media_becomes_selectable() {
248        let mut rec = PrinterRecord::new(t50_config());
249
250        rec.set_ready_media(crate::device::ReadyMedia {
251            name: "om_50x80mm_50x80mm".into(),
252            size_hmm: [5000, 8000],
253            media_type: "labels".into(),
254        });
255
256        assert_eq!(
257            rec.ready_media.as_ref().map(|m| m.name.as_str()),
258            Some("om_50x80mm_50x80mm")
259        );
260        assert!(rec
261            .config
262            .media_names
263            .iter()
264            .any(|n| n == "om_50x80mm_50x80mm"));
265        assert!(rec.config.media_sizes.contains(&[5000, 8000]));
266        // Index 0 is what media-default reports, and the loaded roll is the
267        // sensible default — a restart must not fall back to a size that is
268        // not in the printer.
269        assert_eq!(rec.config.media_names[0], "om_50x80mm_50x80mm");
270        assert_eq!(rec.config.media_sizes[0], [5000, 8000]);
271    }
272
273    /// Swapping rolls must not stack the old size in front, nor duplicate an
274    /// entry the table already had.
275    #[test]
276    fn swapping_rolls_keeps_the_list_tidy() {
277        let mut rec = PrinterRecord::new(t50_config());
278        let roll = |w: i32, h: i32| crate::device::ReadyMedia {
279            name: format!("om_{}x{}mm_{}x{}mm", w / 100, h / 100, w / 100, h / 100),
280            size_hmm: [w, h],
281            media_type: "labels".into(),
282        };
283
284        assert!(
285            rec.set_ready_media(roll(5000, 8000)),
286            "first load is a change"
287        );
288        assert!(!rec.set_ready_media(roll(5000, 8000)), "same roll is not");
289        assert!(rec.set_ready_media(roll(4000, 3000)), "swap is a change");
290
291        // 40x30 was already enumerated; it moved to the front rather than
292        // being added a second time.
293        assert_eq!(rec.config.media_names[0], "om_40x30mm_40x30mm");
294        assert_eq!(
295            rec.config
296                .media_names
297                .iter()
298                .filter(|n| *n == "om_40x30mm_40x30mm")
299                .count(),
300            1,
301            "duplicate entry"
302        );
303        assert_eq!(rec.config.media_names.len(), rec.config.media_sizes.len());
304    }
305
306    /// Re-reporting the same roll every poll must not grow the list.
307    #[test]
308    fn relearning_the_same_roll_is_idempotent() {
309        let mut rec = PrinterRecord::new(t50_config());
310        let media = crate::device::ReadyMedia {
311            name: "om_50x80mm_50x80mm".into(),
312            size_hmm: [5000, 8000],
313            media_type: "labels".into(),
314        };
315        for _ in 0..5 {
316            rec.set_ready_media(media.clone());
317        }
318        assert_eq!(rec.config.media_names.len(), 2);
319        assert_eq!(rec.config.media_sizes.len(), 2);
320    }
321}