feat(printers): niveaux d'encre interroges directement sur l'imprimante

La file driverless ne relaie pas marker-levels: le backend resout l'URI dnssd en point d'acces IPP via ippfind puis lit marker-names/levels/colors via ipptool (timeout 8 s). Le tiroir affiche pastille de couleur, jauge et pourcentage par cartouche; bloc masque si l'imprimante n'expose rien.
This commit is contained in:
Lionel DARNIS 2026-08-08 17:01:41 +02:00
parent c2c7d9ccb1
commit 981e63a154
4 changed files with 258 additions and 2 deletions

View file

@ -273,6 +273,130 @@ pub async fn set_default_option(printer: &str, option: &str, value: &str) -> Res
.await
}
/// A consumable reported by the printer through the IPP marker attributes.
#[derive(Clone, Debug)]
pub struct Marker {
pub name: String,
/// 0-100; negative values mean unknown.
pub level: i32,
/// "#RRGGBB" when the printer advertises a color.
pub color: Option<String>,
}
/// Query ink levels directly from the printer.
///
/// The scheduler does not relay marker levels for driverless queues, so the
/// printer's IPP endpoint is resolved (dnssd -> ippfind) and queried with
/// ipptool. Returns an empty list when the printer does not report markers.
pub async fn markers(device_uri: &str) -> Vec<Marker> {
let Some(endpoint) = resolve_endpoint(device_uri).await else {
return Vec::new();
};
let names = query_marker_attribute(&endpoint, "marker-names").await;
let raw_levels = query_marker_attribute(&endpoint, "marker-levels").await;
let colors = query_marker_attribute(&endpoint, "marker-colors").await;
let levels: Vec<i32> = raw_levels
.iter()
.filter_map(|value| value.trim().parse::<i32>().ok())
.collect();
if levels.is_empty() {
return Vec::new();
}
(0..levels.len())
.map(|index| Marker {
name: names
.get(index)
.cloned()
.unwrap_or_else(|| format!("marker {}", index + 1)),
level: levels[index],
color: colors.get(index).cloned().filter(|value| value.starts_with('#')),
})
.collect()
}
/// Resolve a device URI into a queryable IPP endpoint.
async fn resolve_endpoint(device_uri: &str) -> Option<String> {
if device_uri.starts_with("ipp://") || device_uri.starts_with("ipps://") {
return Some(device_uri.to_string());
}
let rest = device_uri.strip_prefix("dnssd://")?;
let name = rest.split(['/', '?']).next()?;
let name = percent_decode(name.split("._").next()?);
let output = run_cli_capture(
"timeout",
&["8", "ippfind", "_ipp._tcp", "_ipps._tcp", "-N", &name, "-p"],
)
.await
.ok()?;
let mut fallback = None;
for line in output.lines() {
let line = line.trim();
if line.starts_with("ipps://") {
return Some(line.to_string());
}
if fallback.is_none() && line.starts_with("ipp://") {
fallback = Some(line.to_string());
}
}
fallback
}
/// Run one ipptool Get-Printer-Attributes query and return the displayed
/// values of a single marker attribute.
async fn query_marker_attribute(endpoint: &str, attribute: &str) -> Vec<String> {
let test_file = std::env::temp_dir().join(format!("cosmic-settings-printers-{attribute}.test"));
let content = format!(
"{{\n\
\x20 NAME \"{attribute}\"\n\
\x20 OPERATION Get-Printer-Attributes\n\
\x20 GROUP operation-attributes-tag\n\
\x20 ATTR charset attributes-charset utf-8\n\
\x20 ATTR naturalLanguage attributes-natural-language en\n\
\x20 ATTR uri printer-uri $uri\n\
\x20 ATTR keyword requested-attributes {attribute}\n\
\x20 STATUS successful-ok\n\
\x20 DISPLAY {attribute}\n\
}}\n"
);
if tokio::fs::write(&test_file, content).await.is_err() {
return Vec::new();
}
let Some(file_path) = test_file.to_str() else {
return Vec::new();
};
let Ok(output) =
run_cli_capture("timeout", &["8", "ipptool", endpoint, file_path]).await
else {
return Vec::new();
};
parse_display_values(&output).unwrap_or_default()
}
/// Extract the value cell of a single-DISPLAY ipptool table: the line
/// following the dashes separator, split on commas.
fn parse_display_values(output: &str) -> Option<Vec<String>> {
let mut lines = output.lines();
while let Some(line) = lines.next() {
if !line.trim().is_empty() && line.trim().chars().all(|c| c == '-') {
let values = lines.next()?.trim();
return Some(values.split(',').map(|value| value.trim().to_string()).collect());
}
}
None
}
pub async fn set_default(name: &str) -> Result<(), String> {
run_cli("lpadmin", &["-d", name]).await
}

View file

@ -9,7 +9,7 @@
mod backend;
use cosmic::iced::{Alignment, Length, Subscription};
use cosmic::iced::{Alignment, Background, Border, Color, Length, Shadow, Subscription};
use cosmic::widget::{self, text};
use cosmic::{Apply, Element, Task, theme};
use cosmic_settings_page::{self as page, Section, section};
@ -47,6 +47,7 @@ pub struct Page {
jobs: Vec<backend::Job>,
selected: Option<String>,
options: Option<(String, backend::PrinterOptions)>,
markers: Vec<backend::Marker>,
notice: Option<String>,
dialog: Option<Dialog>,
add: AddFlow,
@ -67,6 +68,7 @@ pub enum Message {
CancelJob(i32),
OperationDone(Result<(), String>),
OptionsLoaded(String, Result<backend::PrinterOptions, String>),
MarkersLoaded(Vec<backend::Marker>),
SetOption {
printer: String,
option: String,
@ -173,6 +175,56 @@ fn sides_label(value: &str) -> String {
}
}
fn ink_label(name: &str) -> String {
match name.to_lowercase().as_str() {
"black cartridge" | "black" => fl!("printers-ink-black"),
"cyan cartridge" | "cyan" => fl!("printers-ink-cyan"),
"magenta cartridge" | "magenta" => fl!("printers-ink-magenta"),
"yellow cartridge" | "yellow" => fl!("printers-ink-yellow"),
_ => name.to_string(),
}
}
fn parse_hex_color(value: &str) -> Option<Color> {
let hex = value.trim().trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::from_rgb(
red as f32 / 255.0,
green as f32 / 255.0,
blue as f32 / 255.0,
))
}
fn ink_swatch<Message: 'static>(color: Color) -> Element<'static, Message> {
widget::container(widget::space::horizontal())
.width(Length::Fixed(14.0))
.height(Length::Fixed(14.0))
.class(theme::Container::custom(move |_theme| {
widget::container::Style {
icon_color: None,
text_color: None,
background: Some(Background::Color(color)),
border: Border {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.25),
width: 1.0,
radius: 7.0.into(),
},
shadow: Shadow {
color: Color::TRANSPARENT,
offset: cosmic::iced::Vector::new(0.0, 0.0),
blur_radius: 0.0,
},
snap: true,
}
}))
.apply(Element::from)
}
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> page::Info {
page::Info::new("printers", "printer-symbolic")
@ -199,6 +251,7 @@ impl page::Page<crate::pages::Message> for Page {
self.add = AddFlow::default();
self.selected = None;
self.options = None;
self.markers = Vec::new();
Task::none()
}
@ -281,10 +334,23 @@ impl Page {
Message::Select(name) => {
self.selected = Some(name.clone());
self.options = None;
self.markers = Vec::new();
let open_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
crate::app::Message::OpenContextDrawer(self.entity),
);
return Task::batch([open_drawer, Self::fetch_options(name).map(Into::into)]);
let mut tasks: Vec<cosmic::Task<crate::Message>> = vec![
open_drawer,
Self::fetch_options(name.clone()).map(Into::into),
];
if let Some(device_uri) = self
.printers
.iter()
.find(|printer| printer.name == name)
.and_then(|printer| printer.device_uri.clone())
{
tasks.push(Self::fetch_markers(device_uri).map(Into::into));
}
return Task::batch(tasks);
}
Message::SetDefault(name) => {
@ -316,6 +382,7 @@ impl Page {
};
self.selected = None;
self.options = None;
self.markers = Vec::new();
return Self::admin(async move { backend::remove_printer(&name).await });
}
@ -347,6 +414,10 @@ impl Page {
self.options = result.ok().map(|options| (name, options));
}
Message::MarkersLoaded(markers) => {
self.markers = markers;
}
Message::SetOption {
printer,
option,
@ -449,6 +520,7 @@ impl Page {
Ok(name) => {
self.add = AddFlow::default();
self.options = None;
self.markers = Vec::new();
self.notice = Some(fl!("printers-added", name = name));
let close_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
@ -474,6 +546,13 @@ impl Page {
.map(crate::pages::Message::Printers)
}
fn fetch_markers(device_uri: String) -> Task<crate::pages::Message> {
cosmic::task::future(async move {
Message::MarkersLoaded(backend::markers(&device_uri).await)
})
.map(crate::pages::Message::Printers)
}
fn reload(&self) -> Task<crate::pages::Message> {
Task::batch([
cosmic::task::future(async {
@ -753,6 +832,49 @@ impl Page {
content = content.push(list);
}
if !self.markers.is_empty() {
content = content.push(text::body(fl!("printers-ink-levels")));
let mut rows =
widget::column::with_children(Vec::<Element<'_, Message>>::new())
.spacing(spacing.space_xs);
for marker in &self.markers {
let mut children: Vec<Element<'_, Message>> = Vec::new();
if let Some(color) = marker.color.as_deref().and_then(parse_hex_color) {
children.push(ink_swatch(color));
}
children.push(
text::body(ink_label(&marker.name))
.width(Length::FillPortion(2))
.apply(Element::from),
);
if marker.level >= 0 {
children.push(
widget::progress_bar::determinate_linear(marker.level as f32 / 100.0)
.width(Length::FillPortion(3))
.apply(Element::from),
);
children.push(
text::caption(format!("{} %", marker.level)).apply(Element::from),
);
} else {
children.push(text::caption("—").apply(Element::from));
}
rows = rows.push(
widget::row::with_children(children)
.spacing(spacing.space_s)
.align_y(Alignment::Center),
);
}
content = content.push(rows);
}
let jobs: Vec<&backend::Job> = self
.jobs
.iter()

View file

@ -663,6 +663,11 @@ printers-device = Device
printers-device-uri = Device URI
printers-driver = Driver
printers-error = Operation failed: { $error }
printers-ink-black = Black
printers-ink-cyan = Cyan
printers-ink-levels = Ink levels
printers-ink-magenta = Magenta
printers-ink-yellow = Yellow
printers-job-aborted = Aborted
printers-job-canceled = Canceled
printers-job-completed = Completed

View file

@ -451,6 +451,11 @@ printers-device = Périphérique
printers-device-uri = URI du périphérique
printers-driver = Pilote
printers-error = Échec de l'opération : { $error }
printers-ink-black = Noir
printers-ink-cyan = Cyan
printers-ink-levels = Niveaux d'encre
printers-ink-magenta = Magenta
printers-ink-yellow = Jaune
printers-job-aborted = Interrompu
printers-job-canceled = Annulé
printers-job-completed = Terminé