ipp_printer_app/
status.rs1use std::sync::Arc;
10use std::time::Duration;
11
12use crate::device::DeviceBackend;
13use crate::printer::{PrinterRecord, PrinterRegistry};
14
15pub trait AdvertiserControl: Send + Sync {
21 fn publish(&self, rec: &PrinterRecord);
24 fn withdraw(&self, name: &str);
27 fn is_advertised(&self, name: &str) -> bool;
29}
30
31const POLL_INTERVAL: Duration = Duration::from_secs(30);
33
34pub fn spawn(
37 backend: Arc<dyn DeviceBackend>,
38 registry: PrinterRegistry,
39 advertiser: Option<Arc<dyn AdvertiserControl>>,
40 state_path: std::path::PathBuf,
41) -> tokio::task::JoinHandle<()> {
42 let interval = std::env::var("IPP_PRINTER_APP_POLL_SECS")
43 .ok()
44 .and_then(|s| s.parse().ok())
45 .map(Duration::from_secs)
46 .unwrap_or(POLL_INTERVAL);
47
48 tokio::spawn(async move {
49 let mut ticker = tokio::time::interval(interval);
52 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
53 ticker.tick().await; loop {
55 ticker.tick().await;
56 let media_changed = poll_once(backend.as_ref(), ®istry, advertiser.as_deref()).await;
57 if media_changed {
61 crate::server::Server::persist(®istry, &state_path);
62 }
63 }
64 })
65}
66
67async fn poll_once(
70 backend: &dyn DeviceBackend,
71 registry: &PrinterRegistry,
72 advertiser: Option<&dyn AdvertiserControl>,
73) -> bool {
74 let mut media_changed = false;
75 use crate::flags::PrinterReason;
76 use crate::printer::IppPrinterState;
77 let configs: Vec<_> = {
82 let g = registry.read();
83 g.iter()
84 .filter(|r| matches!(r.state, IppPrinterState::Idle | IppPrinterState::Stopped))
85 .map(|r| r.config.clone())
86 .collect()
87 };
88
89 for cfg in configs {
90 let Some(status) = backend.poll_status(&cfg).await else {
93 continue;
94 };
95
96 let reachable = !status.reasons.contains(PrinterReason::OFFLINE);
100 let mut rec_snapshot: Option<PrinterRecord> = None;
101 {
102 let mut g = registry.write();
103 if let Some(rec) = g.iter_mut().find(|r| r.config.name == cfg.name) {
104 if rec.reasons != status.reasons {
105 log::debug!(
106 "status: {} reasons {:?} -> {:?}",
107 cfg.name,
108 rec.reasons,
109 status.reasons
110 );
111 rec.reasons = status.reasons;
112 }
113 let want = if reachable {
119 IppPrinterState::Idle
120 } else {
121 IppPrinterState::Stopped
122 };
123 if rec.state != want {
124 log::info!("status: {} state {:?} -> {:?}", cfg.name, rec.state, want);
125 rec.state = want;
126 }
127 if let Some(media) = status.ready_media {
129 media_changed |= rec.set_ready_media(media);
130 }
131 if status.supply_percent.is_some() {
132 rec.supply_percent = status.supply_percent;
133 }
134 if advertiser.is_some() {
135 rec_snapshot = Some(rec.clone());
136 }
137 }
138 }
139
140 if let Some(adv) = advertiser {
146 if reachable {
147 if let Some(rec) = rec_snapshot {
148 if !adv.is_advertised(&cfg.name) {
149 adv.publish(&rec);
150 }
151 }
152 } else if adv.is_advertised(&cfg.name) {
153 adv.withdraw(&cfg.name);
154 }
155 }
156 }
157 media_changed
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::device::PollStatus;
164 use crate::flags::PrinterReason;
165 use crate::printer::{IppPrinterState, PrinterConfig, PrinterRecord};
166 use parking_lot::Mutex;
167 use std::collections::HashSet;
168
169 struct FakeBackend {
172 reasons: Mutex<PrinterReason>,
173 }
174 #[async_trait::async_trait]
175 impl DeviceBackend for FakeBackend {
176 async fn list(&self) -> Vec<crate::device::DiscoveredDevice> {
177 Vec::new()
178 }
179 fn driver_for_device(&self, _id: &str, _uri: &str) -> Option<String> {
180 None
181 }
182 async fn poll_status(&self, _config: &PrinterConfig) -> Option<PollStatus> {
183 Some(PollStatus {
184 reasons: *self.reasons.lock(),
185 ready_media: None,
186 supply_percent: None,
187 })
188 }
189 }
190
191 #[derive(Default)]
193 struct FakeAdvertiser {
194 advertised: Mutex<HashSet<String>>,
195 }
196 impl AdvertiserControl for FakeAdvertiser {
197 fn publish(&self, rec: &PrinterRecord) {
198 self.advertised.lock().insert(rec.config.name.clone());
199 }
200 fn withdraw(&self, name: &str) {
201 self.advertised.lock().remove(name);
202 }
203 fn is_advertised(&self, name: &str) -> bool {
204 self.advertised.lock().contains(name)
205 }
206 }
207
208 fn config(name: &str) -> PrinterConfig {
209 PrinterConfig {
210 name: name.into(),
211 display_name: String::new(),
212 driver_name: "t".into(),
213 make_and_model: "Test".into(),
214 device_id: String::new(),
215 device_uri: "mock://x".into(),
216 dpi: 203,
217 printhead_width_dots: 384,
218 media_names: vec![],
219 media_sizes: vec![],
220 media_size_min: [0, 0],
221 media_size_max: [0, 0],
222 darkness: 50,
223 document_formats: vec![],
224 }
225 }
226
227 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
229 async fn offline_stops_and_withdraws_then_recovery_republishes() {
230 let backend = FakeBackend {
231 reasons: Mutex::new(PrinterReason::OFFLINE),
232 };
233 let registry: PrinterRegistry =
234 Arc::new(parking_lot::RwLock::new(vec![PrinterRecord::new(config(
235 "p",
236 ))]));
237 let adv = Arc::new(FakeAdvertiser::default());
238 adv.publish(®istry.read()[0].clone());
240 let advref: &dyn AdvertiserControl = adv.as_ref();
241
242 poll_once(&backend, ®istry, Some(advref)).await;
244 assert_eq!(registry.read()[0].state, IppPrinterState::Stopped);
245 assert!(!adv.is_advertised("p"), "offline printer must be withdrawn");
246
247 *backend.reasons.lock() = PrinterReason::empty();
249 poll_once(&backend, ®istry, Some(advref)).await;
250 assert_eq!(registry.read()[0].state, IppPrinterState::Idle);
251 assert!(
252 adv.is_advertised("p"),
253 "recovered printer must be republished"
254 );
255 }
256
257 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
262 async fn reconcile_republishes_without_a_state_edge() {
263 let backend = FakeBackend {
264 reasons: Mutex::new(PrinterReason::empty()),
265 };
266 let registry: PrinterRegistry =
267 Arc::new(parking_lot::RwLock::new(vec![PrinterRecord::new(config(
268 "p",
269 ))]));
270 let adv = Arc::new(FakeAdvertiser::default());
273 assert!(!adv.is_advertised("p"));
274 let advref: &dyn AdvertiserControl = adv.as_ref();
275
276 poll_once(&backend, ®istry, Some(advref)).await;
277 assert!(
278 adv.is_advertised("p"),
279 "reconcile must restore the advert with no state transition"
280 );
281 }
282}