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:
parent
c2c7d9ccb1
commit
981e63a154
4 changed files with 258 additions and 2 deletions
|
|
@ -273,6 +273,130 @@ pub async fn set_default_option(printer: &str, option: &str, value: &str) -> Res
|
||||||
.await
|
.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> {
|
pub async fn set_default(name: &str) -> Result<(), String> {
|
||||||
run_cli("lpadmin", &["-d", name]).await
|
run_cli("lpadmin", &["-d", name]).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
mod backend;
|
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::widget::{self, text};
|
||||||
use cosmic::{Apply, Element, Task, theme};
|
use cosmic::{Apply, Element, Task, theme};
|
||||||
use cosmic_settings_page::{self as page, Section, section};
|
use cosmic_settings_page::{self as page, Section, section};
|
||||||
|
|
@ -47,6 +47,7 @@ pub struct Page {
|
||||||
jobs: Vec<backend::Job>,
|
jobs: Vec<backend::Job>,
|
||||||
selected: Option<String>,
|
selected: Option<String>,
|
||||||
options: Option<(String, backend::PrinterOptions)>,
|
options: Option<(String, backend::PrinterOptions)>,
|
||||||
|
markers: Vec<backend::Marker>,
|
||||||
notice: Option<String>,
|
notice: Option<String>,
|
||||||
dialog: Option<Dialog>,
|
dialog: Option<Dialog>,
|
||||||
add: AddFlow,
|
add: AddFlow,
|
||||||
|
|
@ -67,6 +68,7 @@ pub enum Message {
|
||||||
CancelJob(i32),
|
CancelJob(i32),
|
||||||
OperationDone(Result<(), String>),
|
OperationDone(Result<(), String>),
|
||||||
OptionsLoaded(String, Result<backend::PrinterOptions, String>),
|
OptionsLoaded(String, Result<backend::PrinterOptions, String>),
|
||||||
|
MarkersLoaded(Vec<backend::Marker>),
|
||||||
SetOption {
|
SetOption {
|
||||||
printer: String,
|
printer: String,
|
||||||
option: 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 {
|
impl page::Page<crate::pages::Message> for Page {
|
||||||
fn info(&self) -> page::Info {
|
fn info(&self) -> page::Info {
|
||||||
page::Info::new("printers", "printer-symbolic")
|
page::Info::new("printers", "printer-symbolic")
|
||||||
|
|
@ -199,6 +251,7 @@ impl page::Page<crate::pages::Message> for Page {
|
||||||
self.add = AddFlow::default();
|
self.add = AddFlow::default();
|
||||||
self.selected = None;
|
self.selected = None;
|
||||||
self.options = None;
|
self.options = None;
|
||||||
|
self.markers = Vec::new();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,10 +334,23 @@ impl Page {
|
||||||
Message::Select(name) => {
|
Message::Select(name) => {
|
||||||
self.selected = Some(name.clone());
|
self.selected = Some(name.clone());
|
||||||
self.options = None;
|
self.options = None;
|
||||||
|
self.markers = Vec::new();
|
||||||
let open_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
|
let open_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
|
||||||
crate::app::Message::OpenContextDrawer(self.entity),
|
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) => {
|
Message::SetDefault(name) => {
|
||||||
|
|
@ -316,6 +382,7 @@ impl Page {
|
||||||
};
|
};
|
||||||
self.selected = None;
|
self.selected = None;
|
||||||
self.options = None;
|
self.options = None;
|
||||||
|
self.markers = Vec::new();
|
||||||
return Self::admin(async move { backend::remove_printer(&name).await });
|
return Self::admin(async move { backend::remove_printer(&name).await });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,6 +414,10 @@ impl Page {
|
||||||
self.options = result.ok().map(|options| (name, options));
|
self.options = result.ok().map(|options| (name, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Message::MarkersLoaded(markers) => {
|
||||||
|
self.markers = markers;
|
||||||
|
}
|
||||||
|
|
||||||
Message::SetOption {
|
Message::SetOption {
|
||||||
printer,
|
printer,
|
||||||
option,
|
option,
|
||||||
|
|
@ -449,6 +520,7 @@ impl Page {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
self.add = AddFlow::default();
|
self.add = AddFlow::default();
|
||||||
self.options = None;
|
self.options = None;
|
||||||
|
self.markers = Vec::new();
|
||||||
self.notice = Some(fl!("printers-added", name = name));
|
self.notice = Some(fl!("printers-added", name = name));
|
||||||
|
|
||||||
let close_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
|
let close_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
|
||||||
|
|
@ -474,6 +546,13 @@ impl Page {
|
||||||
.map(crate::pages::Message::Printers)
|
.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> {
|
fn reload(&self) -> Task<crate::pages::Message> {
|
||||||
Task::batch([
|
Task::batch([
|
||||||
cosmic::task::future(async {
|
cosmic::task::future(async {
|
||||||
|
|
@ -753,6 +832,49 @@ impl Page {
|
||||||
content = content.push(list);
|
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
|
let jobs: Vec<&backend::Job> = self
|
||||||
.jobs
|
.jobs
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
5
i18n/en/cosmic_settings.ftl
vendored
5
i18n/en/cosmic_settings.ftl
vendored
|
|
@ -663,6 +663,11 @@ printers-device = Device
|
||||||
printers-device-uri = Device URI
|
printers-device-uri = Device URI
|
||||||
printers-driver = Driver
|
printers-driver = Driver
|
||||||
printers-error = Operation failed: { $error }
|
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-aborted = Aborted
|
||||||
printers-job-canceled = Canceled
|
printers-job-canceled = Canceled
|
||||||
printers-job-completed = Completed
|
printers-job-completed = Completed
|
||||||
|
|
|
||||||
5
i18n/fr/cosmic_settings.ftl
vendored
5
i18n/fr/cosmic_settings.ftl
vendored
|
|
@ -451,6 +451,11 @@ printers-device = Périphérique
|
||||||
printers-device-uri = URI du périphérique
|
printers-device-uri = URI du périphérique
|
||||||
printers-driver = Pilote
|
printers-driver = Pilote
|
||||||
printers-error = Échec de l'opération : { $error }
|
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-aborted = Interrompu
|
||||||
printers-job-canceled = Annulé
|
printers-job-canceled = Annulé
|
||||||
printers-job-completed = Terminé
|
printers-job-completed = Terminé
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue