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
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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-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
|
||||
|
|
|
|||
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-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é
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue