feat(input): page Logitech pilotee par l'agent OpenLogi (pile, DPI, SmartShift)

This commit is contained in:
Lionel DARNIS 2026-09-10 21:06:02 +02:00
parent 0fec31749b
commit 663c17ace3
8 changed files with 1368 additions and 43 deletions

798
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,10 @@ iced_winit.workspace = true
locale1 = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true }
sysinfo = { version = "=0.38.0", optional = true }
mime-apps = { package = "cosmic-mime-apps", git = "https://github.com/pop-os/cosmic-mime-apps", features = ["tokio"], optional = true }
# Intégration OpenLogi (page Logitech des périphériques d'entrée).
openlogi-core = { path = "../../OpenLogi/crates/openlogi-core" }
openlogi-ipc = { path = "../../OpenLogi/crates/openlogi-ipc" }
tarpc = { version = "0.38", features = ["serde1", "tokio1"] }
notify = "8.2.0"
nmrs = { version = "3.5.2", optional = true }
regex = "1.13.1"
@ -68,7 +72,7 @@ slotmap = "1.1.1"
static_init = "1.0.4"
sunrise = "3.0.0"
timedate-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true }
tokio = { workspace = true, features = ["fs", "io-util", "process", "sync"] }
tokio = { workspace = true, features = ["fs", "io-util", "process", "sync", "time"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
udev = { version = "0.9.3", optional = true }

View file

@ -515,6 +515,13 @@ impl cosmic::Application for SettingsApp {
}
}
#[cfg(feature = "page-input")]
crate::pages::Message::Logitech(message) => {
if let Some(page) = self.pages.page_mut::<input::logitech::Page>() {
return page.update(message).map(Into::into);
}
}
#[cfg(feature = "page-input")]
crate::pages::Message::KeyboardShortcuts(message) => {
if let Some(page) = self.pages.page_mut::<input::keyboard::shortcuts::Page>() {

View file

@ -0,0 +1,551 @@
// Copyright 2026 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
//
// Page « Logitech » — intégration OpenLogi dans les périphériques d'entrée.
// Pilotée par l'agent OpenLogi (openlogi-agent) via son IPC tarpc local.
// Les réglages permanents (remappage complet, profils) restent dans la GUI
// OpenLogi ; cette page couvre le quotidien : pile, DPI, SmartShift.
use cosmic::widget::{self, button, row, settings, text};
use cosmic::{Apply, Element, Task};
use cosmic_settings_page::{self as page, Section, section};
use openlogi_core::device::{BatteryInfo, DeviceKind};
use openlogi_core::hid::{
DeviceRoute, Dpi, DpiInfo, SmartShiftAutoDisengage, SmartShiftMode, SmartShiftStatus,
SmartShiftThreshold, WriteError,
};
use openlogi_ipc::{client, AgentClient, AgentSnapshot};
use slotmap::SlotMap;
use tarpc::context;
/// Clé d'identification stable d'un appareil dans les caches de la page.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct DeviceKey {
pub receiver: String,
pub slot: u8,
}
/// Ce que la page a réussi à lire pour un appareil donné.
/// `None` = non supporté (on n'affiche pas le réglage) ; `Some(Err)` = erreur.
#[derive(Clone, Debug, Default)]
pub struct DeviceReads {
pub dpi: Option<Result<DpiInfo, String>>,
pub smartshift: Option<Result<SmartShiftStatus, String>>,
}
/// Un appareil prêt à afficher : inventaire + lectures.
#[derive(Clone, Debug)]
pub struct DeviceEntry {
pub key: DeviceKey,
pub route: Option<DeviceRoute>,
pub slot: u8,
pub name: String,
pub online: bool,
pub battery: Option<BatteryInfo>,
pub pointer: bool,
pub reads: DeviceReads,
}
#[derive(Clone, Debug)]
pub struct SnapshotData {
pub agent_version: String,
pub hook_installed: bool,
pub devices: Vec<DeviceEntry>,
pub offline_count: usize,
}
#[derive(Clone, Debug)]
pub enum Message {
/// Résultat d'un rafraîchissement complet (None = agent injoignable).
Snapshot(Option<SnapshotData>),
/// Écriture DPI envoyée à l'agent ; maj optimiste du cache.
SetDpi(DeviceKey, DeviceRoute, Dpi),
/// Écriture SmartShift ; maj optimiste du cache.
SetSmartShift(DeviceKey, DeviceRoute, bool),
/// Confirmation (ou échec) d'une écriture, par relecture.
WriteConfirmed(DeviceKey, DeviceReads),
/// Lancer la GUI OpenLogi complète.
OpenApp,
/// Démarrer l'agent s'il ne tourne pas.
StartAgent,
/// Rafraîchir.
Refresh,
}
fn deliver(message: Message) -> crate::app::Message {
crate::app::Message::PageMessage(crate::pages::Message::Logitech(message))
}
#[derive(Default)]
pub struct Page {
entity: page::Entity,
on_enter_handle: Option<cosmic::iced::task::Handle>,
/// `None` : pas encore de réponse de l'agent. `Some(None)` : agent absent.
pub snapshot: Option<Option<SnapshotData>>,
}
impl page::Page<crate::pages::Message> for Page {
fn set_id(&mut self, entity: page::Entity) {
self.entity = entity;
}
fn on_enter(&mut self) -> Task<crate::pages::Message> {
self.start_refresh()
}
fn on_leave(&mut self) -> Task<crate::pages::Message> {
if let Some(handle) = self.on_enter_handle.take() {
handle.abort();
}
Task::none()
}
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(devices()), sections.insert(more())])
}
fn info(&self) -> page::Info {
page::Info::new("logitech", "input-mouse-symbolic")
.title(fl!("logitech"))
.description(fl!("logitech-comment"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {}
impl Page {
pub fn update(&mut self, message: Message) -> Task<crate::app::Message> {
match message {
Message::Snapshot(data) => {
self.snapshot = Some(data);
Task::none()
}
Message::SetDpi(key, route, dpi) => {
let write_route = route.clone();
Self::write(write_route, key, move |client| {
let dpi = dpi;
async move {
client
.set_dpi(context::current(), route.clone(), dpi)
.await
}
})
}
Message::SetSmartShift(key, route, enabled) => {
let previous = self
.snapshot
.as_ref()
.and_then(Option::as_ref)
.and_then(|s| s.devices.iter().find(|d| d.key == key))
.and_then(|d| d.reads.smartshift.clone())
.and_then(|r| r.ok());
// SmartShift activé : molette libre avec seuil de bascule.
// Désactivé : cliquet permanent (comme Options+).
let status = match (enabled, previous) {
(true, Some(previous)) => SmartShiftStatus {
mode: SmartShiftMode::Free,
..previous
},
(false, Some(previous)) => SmartShiftStatus {
mode: SmartShiftMode::Ratchet,
auto_disengage: SmartShiftAutoDisengage::Permanent,
..previous
},
(true, None) => SmartShiftStatus {
mode: SmartShiftMode::Free,
auto_disengage: SmartShiftAutoDisengage::Threshold(
SmartShiftThreshold::from_rounded(10.0),
),
tunable_torque: None,
},
(false, None) => SmartShiftStatus {
mode: SmartShiftMode::Ratchet,
auto_disengage: SmartShiftAutoDisengage::Permanent,
tunable_torque: None,
},
};
let write_route = route.clone();
Self::write(write_route, key, move |client| {
let status = status;
async move {
client
.set_smartshift(context::current(), route.clone(), status)
.await
}
})
}
Message::WriteConfirmed(..) => Task::none(),
Message::OpenApp => {
if let Err(err) = std::process::Command::new("openlogi-desktop").spawn() {
tracing::warn!("failed to launch openlogi-desktop: {err}");
}
Task::none()
}
Message::StartAgent => Task::future(async move {
_ = tokio::process::Command::new("systemctl")
.args(["--user", "start", "openlogi-agent.service"])
.status()
.await;
// Laisse le socket de l'agent s'ouvrir avant de réessayer.
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
deliver(Message::Refresh)
}),
Message::Refresh => {
self.start_refresh().map(crate::app::Message::PageMessage)
}
}
}
/// Rafraîchit l'inventaire : connexion à l'agent, snapshot, puis lectures
/// DPI/SmartShift de chaque appareil pointeur en ligne.
fn start_refresh(&mut self) -> Task<crate::pages::Message> {
if let Some(handle) = self.on_enter_handle.take() {
handle.abort();
}
let (task, handle) = Task::future(async move {
crate::pages::Message::Logitech(Message::Snapshot(fetch_snapshot().await))
})
.abortable();
self.on_enter_handle = Some(handle);
task
}
/// Envoie une écriture à l'agent, puis relit l'appareil pour confirmer.
fn write<F, Fut>(route: DeviceRoute, key: DeviceKey, f: F) -> Task<crate::app::Message>
where
F: FnOnce(AgentClient) -> Fut + Send + 'static,
Fut: std::future::Future<
Output = Result<Result<(), WriteError>, tarpc::client::RpcError>,
> + Send,
{
let read_route = route.clone();
Task::future(async move {
if let Ok(connection) = client::connect().await {
let result = f(connection.client).await;
if !matches!(result, Ok(Ok(()))) {
tracing::warn!("openlogi write failed: {result:?}");
}
}
// Relit l'appareil : la valeur affichée redevient la vérité.
let mut reads = DeviceReads::default();
if let Ok(connection) = client::connect().await {
reads.dpi = read_dpi(&connection.client, &read_route).await;
reads.smartshift = read_smartshift(&connection.client, &read_route).await;
}
deliver(Message::WriteConfirmed(key, reads))
})
}
}
/// Une erreur qui signale « l'appareil n'a pas cette fonctionnalité » :
/// le réglage correspondant ne doit simplement pas s'afficher.
fn is_unsupported(err: &WriteError) -> bool {
matches!(
err,
WriteError::FeatureUnsupported { .. } | WriteError::EmptyDpiList
)
}
async fn read_dpi(client: &AgentClient, route: &DeviceRoute) -> Option<Result<DpiInfo, String>> {
match client.read_dpi(context::current(), route.clone()).await {
Ok(Ok(info)) => Some(Ok(info)),
Ok(Err(err)) if is_unsupported(&err) => None,
Ok(Err(err)) => Some(Err(err.to_string())),
Err(err) => Some(Err(err.to_string())),
}
}
async fn read_smartshift(
client: &AgentClient,
route: &DeviceRoute,
) -> Option<Result<SmartShiftStatus, String>> {
match client
.read_smartshift(context::current(), route.clone())
.await
{
Ok(Ok(status)) => Some(Ok(status)),
Ok(Err(err)) if is_unsupported(&err) => None,
Ok(Err(err)) => Some(Err(err.to_string())),
Err(err) => Some(Err(err.to_string())),
}
}
/// Connexion + snapshot + lectures par appareil, en une passe.
async fn fetch_snapshot() -> Option<SnapshotData> {
let connection = client::connect().await.ok()?;
let AgentSnapshot {
status, inventory, ..
} = connection.client.snapshot(context::current()).await.ok()?;
let mut devices = Vec::new();
let mut offline_count = 0usize;
for inv in &inventory {
let receiver = inv
.receiver
.unique_id
.clone()
.unwrap_or_else(|| inv.receiver.name.clone());
for paired in &inv.paired {
if !paired.online {
offline_count += 1;
}
let route = DeviceRoute::device_route_for(inv, paired.slot);
let pointer = paired.kind == DeviceKind::Mouse;
// Ne parle qu'aux pointeurs en ligne, via leur route.
let reads = if paired.online && pointer {
match &route {
Some(route) => DeviceReads {
dpi: read_dpi(&connection.client, route).await,
smartshift: read_smartshift(&connection.client, route).await,
},
None => DeviceReads::default(),
}
} else {
DeviceReads::default()
};
devices.push(DeviceEntry {
key: DeviceKey {
receiver: receiver.clone(),
slot: paired.slot,
},
route,
slot: paired.slot,
name: paired.codename.clone().unwrap_or_else(|| {
fl!("logitech", "device-slot", slot = u32::from(paired.slot))
}),
online: paired.online,
battery: paired.battery.clone(),
pointer,
reads,
});
}
}
Some(SnapshotData {
agent_version: status.agent_version,
hook_installed: status.hook_installed,
devices,
offline_count,
})
}
/// La page ne se lie que si du matériel Logitech est présent (comme le
/// touchpad) : périphérique HID dont le vendor ID est 046d.
pub fn system_has_logitech() -> bool {
let Ok(mut enumerator) = udev::Enumerator::new() else {
return false;
};
let _res = enumerator.match_subsystem("hid");
let Ok(mut devices) = enumerator.scan_devices() else {
return false;
};
devices.any(|device| {
device
.property_value("HID_ID")
.and_then(|value| value.to_str())
.is_some_and(|id| {
// HID_ID = "bustype:vendor:product" en hexadécimal.
id.split(':')
.nth(1)
.is_some_and(|vendor| vendor.eq_ignore_ascii_case("0000046D"))
})
})
}
fn devices() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
dpi = fl!("logitech", "dpi");
smartshift = fl!("logitech", "smartshift");
smartshift_desc = fl!("logitech", "smartshift-desc");
battery = fl!("logitech", "battery");
offline = fl!("logitech", "offline");
online = fl!("logitech", "online");
no_agent = fl!("logitech", "no-agent");
no_agent_desc = fl!("logitech", "no-agent-desc");
start_agent = fl!("logitech", "start-agent");
refresh = fl!("logitech", "refresh");
no_devices = fl!("logitech", "no-devices");
});
Section::default()
.title(fl!("logitech"))
.descriptions(descriptions)
.view::<Page>(move |binder, _page, section| {
let descriptions = &section.descriptions;
let logitech = binder.page::<Page>().expect("logitech page not found");
let mut list = settings::section().title(&section.title);
match &logitech.snapshot {
// Pas encore de réponse : proposition de rafraîchissement.
None => {
list = list.add(settings::item(
"",
button::text(&descriptions[refresh])
.on_press(crate::pages::Message::Logitech(Message::Refresh)),
));
}
// Agent injoignable : explication + remise en route.
Some(None) => {
let actions = row(vec![
button::text(&descriptions[start_agent])
.on_press(crate::pages::Message::Logitech(Message::StartAgent))
.into(),
button::text(fl!("logitech", "open-app"))
.on_press(crate::pages::Message::Logitech(Message::OpenApp))
.into(),
])
.spacing(8);
list = list
.add(settings::item(
&descriptions[no_agent],
text::body(&descriptions[no_agent_desc]),
))
.add(settings::item("", actions));
}
Some(Some(snapshot)) => {
if snapshot.devices.is_empty() {
list = list
.add(settings::item(&descriptions[no_devices], text::body("")));
}
for device in &snapshot.devices {
let mut details = Vec::new();
if let Some(batt) = &device.battery {
details
.push(format!("{} · {} %", descriptions[battery], batt.percentage));
}
details.push(if device.online {
descriptions[online].to_string()
} else {
descriptions[offline].to_string()
});
let details = details.join(" · ");
let Some(route) = device.route.clone() else {
list = list
.add(settings::item(&device.name, text::body(details)));
continue;
};
let item;
if let Some(Ok(dpi_info)) = &device.reads.dpi {
let dpi_info = dpi_info.clone();
let values: Vec<String> = dpi_info
.capabilities
.values()
.iter()
.map(|dpi| dpi.to_string())
.collect();
let selected = dpi_info
.capabilities
.values()
.iter()
.position(|dpi| *dpi == dpi_info.current);
let key = device.key.clone();
let dpi_route = route.clone();
let dpi_control = widget::dropdown(values, selected, move |index| {
let Some(dpi) =
dpi_info.capabilities.values().get(index).copied()
else {
return crate::pages::Message::Logitech(Message::Refresh);
};
crate::pages::Message::Logitech(Message::SetDpi(
key.clone(),
dpi_route.clone(),
dpi,
))
});
item = settings::item::builder(device.name.clone())
.description(details)
.control(dpi_control);
} else {
item = settings::item::builder(device.name.clone())
.description(details)
.control(text::body(""));
}
list = list.add(item);
if let Some(Ok(status)) = &device.reads.smartshift {
let status = *status;
let enabled = status.mode == SmartShiftMode::Free;
let key = device.key.clone();
list = list.add(
settings::item::builder(&descriptions[smartshift])
.description(&descriptions[smartshift_desc])
.toggler(
enabled,
move |value| {
crate::pages::Message::Logitech(
Message::SetSmartShift(
key.clone(),
route.clone(),
value,
),
)
},
),
);
}
}
}
}
list.apply(Element::from)
})
}
fn more() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
open_app = fl!("logitech", "open-app");
open_app_desc = fl!("logitech", "open-app-desc");
});
Section::default()
.title(fl!("logitech", "more"))
.descriptions(descriptions)
.view::<Page>(move |_binder, _page, section| {
let descriptions = &section.descriptions;
settings::section()
.title(&section.title)
.add(
settings::item::builder(&descriptions[open_app])
.description(&descriptions[open_app_desc])
.control(button::text(&descriptions[open_app]).on_press(
crate::pages::Message::Logitech(Message::OpenApp),
)),
)
.apply(Element::from)
})
.into()
}

View file

@ -12,6 +12,7 @@ use cosmic_settings_page as page;
use tracing::error;
pub mod keyboard;
pub mod logitech;
pub mod mouse;
pub mod touchpad;
@ -194,6 +195,12 @@ impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(page: page::Insert<crate::pages::Message>) -> page::Insert<crate::pages::Message> {
let insert = page.sub_page::<keyboard::Page>().sub_page::<mouse::Page>();
let insert = if logitech::system_has_logitech() {
insert.sub_page::<logitech::Page>()
} else {
insert
};
if system_has_touchpad() {
insert.sub_page::<touchpad::Page>()
} else {

View file

@ -66,6 +66,8 @@ pub enum Message {
#[cfg(feature = "page-input")]
Keyboard(input::keyboard::Message),
#[cfg(feature = "page-input")]
Logitech(input::logitech::Message),
#[cfg(feature = "page-input")]
KeyboardShortcuts(input::keyboard::shortcuts::Message),
#[cfg(feature = "page-legacy-applications")]
LegacyApplications(applications::legacy_applications::Message),

View file

@ -994,6 +994,26 @@ mouse = Mouse
.speed = Mouse speed
.acceleration = Enable mouse acceleration
## Input: Logitech (OpenLogi)
logitech = Logitech
.device-slot = Logitech device {$slot}
.battery = Battery
.online = Online
.offline = Offline
.dpi = Sensitivity (DPI)
.smartshift = SmartShift wheel
.smartshift-desc = Let the wheel switch freely to fast scrolling when spun
.no-agent = The OpenLogi agent is not running
.no-agent-desc = Start the agent to configure paired Logitech devices from here
.start-agent = Start the agent
.open-app = Open OpenLogi
.open-app-desc = Button remapping, profiles and advanced settings live in the OpenLogi app
.refresh = Refresh
.no-devices = No paired Logitech device was found
.more = More settings
logitech-comment = Logitech mice and keyboards, through OpenLogi
## Input: Touchpad
click-behavior = Click Behavior

View file

@ -766,6 +766,26 @@ mouse = Souris
.speed = Vitesse de la souris
.acceleration = Activer l'accélération de la souris
## Input: Logitech (OpenLogi)
logitech = Logitech
.device-slot = Appareil Logitech {$slot}
.battery = Pile
.online = En ligne
.offline = Hors ligne
.dpi = Sensibilité (DPI)
.smartshift = Molette SmartShift
.smartshift-desc = La molette passe en défilement libre quand elle est lancée vite
.no-agent = L'agent OpenLogi n'est pas lancé
.no-agent-desc = Démarrez l'agent pour configurer les appareils Logitech appairés depuis ici
.start-agent = Démarrer l'agent
.open-app = Ouvrir OpenLogi
.open-app-desc = Remappage des boutons, profils et réglages avancés dans l'application OpenLogi
.refresh = Rafraîchir
.no-devices = Aucun appareil Logitech appairé trouvé
.more = Plus de réglages
logitech-comment = Souris et claviers Logitech, via OpenLogi
## Input: Touchpad
click-behavior = Comportement de clic