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}