diff --git a/battery-wireless-devices/BarWidget.qml b/battery-wireless-devices/BarWidget.qml new file mode 100644 index 000000000..0743ffb38 --- /dev/null +++ b/battery-wireless-devices/BarWidget.qml @@ -0,0 +1,278 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets +import qs.Services.UI + +// One pill per enabled device: a device icon inside a circular battery ring. +// Live battery data comes from Main.qml via pluginApi.mainInstance.devices; +// per-device appearance/behaviour comes from pluginApi.pluginSettings.devices. +Item { + id: root + + // Plugin API (injected by PluginService). + property var pluginApi: null + + // Required properties for bar widgets. + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property string screenName: screen ? screen.name : "" + readonly property string barPosition: Settings.getBarPositionForScreen(screenName) + readonly property bool isBarVertical: barPosition === "left" || barPosition === "right" + // Pills are sized to the capsule height (smaller than the full bar height) + // so the bar centers them, matching the core widgets. + readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) + + // Live data from Main.qml. + readonly property var liveDevices: (pluginApi && pluginApi.mainInstance) ? (pluginApi.mainInstance.devices || []) : [] + + // Devices the user has enabled, merged with their live battery readings. + readonly property var shownDevices: { + var settings = pluginApi ? pluginApi.pluginSettings : null // dependency for re-eval + var out = [] + for (var i = 0; i < liveDevices.length; i++) { + var d = liveDevices[i] + var cfg = cfgFor(d.id) + if (cfg && cfg.enabled === true) + out.push(d) + } + // Order to match the settings list (top = left-most / top-most). + var order = (settings && settings.deviceOrder) ? settings.deviceOrder : [] + out.sort(function (a, b) { + var ia = order.indexOf(a.id) + var ib = order.indexOf(b.id) + return (ia < 0 ? 9999 : ia) - (ib < 0 ? 9999 : ib) + }) + return out + } + + readonly property bool showPercentage: pluginApi && pluginApi.pluginSettings + && pluginApi.pluginSettings.showPercentage === true + + readonly property bool showCharging: pluginApi && pluginApi.pluginSettings + && pluginApi.pluginSettings.showCharging === true + + readonly property real barFontSize: Style.fontSizeXS + + implicitWidth: layout.implicitWidth + implicitHeight: layout.implicitHeight + visible: shownDevices.length > 0 + + // ---- Helpers ----------------------------------------------------------- + + function cfgFor(id) { + var all = (pluginApi && pluginApi.pluginSettings) ? pluginApi.pluginSettings.devices : null + return (all && all[id]) ? all[id] : null + } + + function defaultIconForType(type) { + switch (type) { + case "mouse": return "mouse" + case "keyboard": return "keyboard" + case "headset": return "headphones" + case "gamepad": return "device-gamepad" + default: return "battery" + } + } + + // Theme color-role keys ("none"/"primary"/"secondary"/"tertiary"/"error"), + // resolved with the same helper the core widgets use. + function ringColorFor(device, cfg) { + var key = cfg ? cfg.ringColor : "" + if (key && key !== "" && key !== "none") + return Color.resolveColorKey(key) + // Auto: colour by charge state / level. + if (device.charging) + return Color.mTertiary + var b = (typeof device.battery === "number") ? device.battery : 100 + if (b <= 15) return Color.mError + if (b <= 35) return Color.mSecondary + return Color.mPrimary + } + + function launch(cfg) { + if (cfg && cfg.launchCmd && cfg.launchCmd.trim() !== "") + Quickshell.execDetached(["sh", "-lc", cfg.launchCmd]) + } + + // ---- Right-click menu (shared) ----------------------------------------- + + NPopupContextMenu { + id: contextMenu + model: [ + { "label": pluginApi?.tr("menu.settings"), "action": "settings", "icon": "settings" }, + { "label": pluginApi?.tr("menu.refresh"), "action": "refresh", "icon": "refresh" } + ] + onTriggered: action => { + contextMenu.close() + PanelService.closeContextMenu(root.screen) + if (action === "settings") + BarService.openPluginSettings(root.screen, pluginApi.manifest) + else if (action === "refresh" && pluginApi && pluginApi.mainInstance) + pluginApi.mainInstance.refresh() + } + } + + // ---- Layout ------------------------------------------------------------ + + GridLayout { + id: layout + anchors.centerIn: parent + columns: root.isBarVertical ? 1 : Math.max(1, shownDevices.length) + rowSpacing: Style.marginXS + columnSpacing: Style.marginXS + + Repeater { + model: root.shownDevices + + delegate: Rectangle { + id: pill + + required property var modelData + readonly property var cfg: root.cfgFor(modelData.id) + readonly property int battery: (typeof modelData.battery === "number") ? modelData.battery : -1 + readonly property color ringColor: root.ringColorFor(modelData, cfg) + readonly property color iconColor: Color.resolveColorKey((cfg && cfg.iconColor) ? cfg.iconColor : "none") + readonly property string iconName: (cfg && cfg.icon) ? cfg.icon : root.defaultIconForType(modelData.type) + + readonly property string tooltipLabel: modelData.name + + (pill.battery >= 0 ? " — " + pill.battery + "%" : "") + + (modelData.charging ? " ⚡" : "") + + implicitWidth: pillRow.implicitWidth + Style.marginM * 2 + implicitHeight: root.capsuleHeight + radius: Style.radiusM + color: pillMouse.containsMouse ? Color.mHover : Style.capsuleColor + + Behavior on color { + enabled: !Color.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.InOutQuad + } + } + + RowLayout { + id: pillRow + anchors.centerIn: parent + spacing: Style.marginXS + + // Circular battery ring with the device icon in the centre. + Item { + id: ring + readonly property real size: Math.round(root.capsuleHeight * 0.82) + implicitWidth: size + implicitHeight: size + Layout.alignment: Qt.AlignVCenter + + Canvas { + id: ringCanvas + anchors.fill: parent + renderStrategy: Canvas.Cooperative + renderTarget: Canvas.FramebufferObject + + readonly property real ratio: pill.battery >= 0 ? Math.max(0, Math.min(1, pill.battery / 100)) : 0 + // Repaint when any visual input changes. + onRatioChanged: requestPaint() + property color trackColor: Qt.alpha(Color.mOnSurface, 0.22) + property color valueColor: pill.ringColor + onValueColorChanged: requestPaint() + onTrackColorChanged: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var lw = Math.max(2, Math.round(width * 0.11)) + var cx = width / 2 + var cy = height / 2 + var r = (width - lw) / 2 + var start = -Math.PI / 2 // 12 o'clock + + ctx.lineWidth = lw + ctx.lineCap = "round" + + // Track + ctx.strokeStyle = trackColor + ctx.beginPath() + ctx.arc(cx, cy, r, 0, 2 * Math.PI) + ctx.stroke() + + // Value arc + if (ratio > 0.001) { + ctx.strokeStyle = valueColor + ctx.beginPath() + ctx.arc(cx, cy, r, start, start + ratio * 2 * Math.PI) + ctx.stroke() + } + } + } + + NIcon { + anchors.centerIn: parent + icon: pill.iconName + color: pill.iconColor + applyUiScale: false // ring.size already accounts for UI scale + pointSize: Math.round(ring.size * 0.5) + } + } + + NIcon { + visible: root.showCharging && modelData.charging + icon: "bolt" + color: Color.mTertiary + applyUiScale: false + pointSize: Math.round(root.capsuleHeight * 0.4) + Layout.alignment: Qt.AlignVCenter + } + + NText { + visible: root.showPercentage && pill.battery >= 0 + text: pill.battery + "%" + color: Color.mOnSurface + pointSize: root.barFontSize + Layout.alignment: Qt.AlignVCenter + } + } + + MouseArea { + id: pillMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + TooltipService.hide() + if (mouse.button === Qt.RightButton) + PanelService.showContextMenu(contextMenu, pill, root.screen) + else + root.launch(pill.cfg) + } + onEntered: { + TooltipService.show(pill, pill.tooltipLabel, BarService.getTooltipDirection(root.screenName)) + tooltipRefreshTimer.start() + } + onExited: { + tooltipRefreshTimer.stop() + TooltipService.hide() + } + + // Keep the tooltip percentage current while hovering. + Timer { + id: tooltipRefreshTimer + interval: 1000 + repeat: true + onTriggered: { + if (pillMouse.containsMouse) + TooltipService.updateText(pill.tooltipLabel) + } + } + } + } + } + } +} diff --git a/battery-wireless-devices/CLAUDE.md b/battery-wireless-devices/CLAUDE.md new file mode 100644 index 000000000..86767384f --- /dev/null +++ b/battery-wireless-devices/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A plugin for **Noctalia** (a Quickshell-based desktop shell, `noctalia-shell`). It is pure QML loaded at runtime by the running shell — there is **no build step, no compiler, and no test framework** in this repo. The plugin's purpose is a bar widget that shows battery indicators for connected peripherals (e.g. mouse, keyboard, headphones). Device data does **not** come from any Noctalia service — it is gathered by `scripts/scan.py` (see below). + +Requires `minNoctaliaVersion: 4.6.6`. + +## This implementation's architecture + +The plugin is split across three entry points plus one helper script: + +- **`scripts/scan.py`** — the data layer and the only place that knows how to talk to devices. It probes every supported source and prints a **normalized JSON array** on stdout: `[{ id, name, type, battery, charging, source }]`. `id` is `":"`. Each source is a `scan_()` function that swallows its own errors (missing tool → `[]`). Current sources: `scan_openrazer()` (via the `openrazer` Python client / running `openrazer-daemon`) and `scan_solaar()` (parses `solaar show`). **To add a new device source, add a `scan_*()` and append it to `SOURCES` — nothing in the QML changes.** +- **`Main.qml`** (`main` entry point) — runs `scan.py` on a `Timer` (interval = `pluginSettings.refreshInterval`, default 60s), parses the JSON, and exposes the live list as `property var devices`. Also exposes `refresh()` and an `IpcHandler` (`plugin:battery-wireless-devices` → `refresh`). +- **`BarWidget.qml`** — reads live data from `pluginApi.mainInstance.devices` and per-device config from `pluginApi.pluginSettings.devices`. Renders one pill per device the user has **enabled**, each a device icon inside a circular battery ring (a `Canvas`). Ring colour is auto-by-level unless overridden; click runs the device's `launchCmd` via `Quickshell.execDetached`. +- **`Settings.qml`** (`settings` entry point) — lists detected + previously-configured devices (drag the handle to reorder; top = left-most / top-most in the bar) and lets the user set per device: `enabled`, `icon`, `iconColor`, `ringColor`, `launchCmd`. Settings shape: `pluginSettings.devices` is a map keyed by device `id`, `pluginSettings.deviceOrder` is an ordered array of ids; globals are `refreshInterval` and `showPercentage`. Edits apply **live** (debounced `commit()` rebuilds `devices` with fresh object identities so the bar's bindings re-fire); the popup's Apply button is redundant. `populateList()` also auto-heals stale duplicate entries (same source + name, different id). + +Data flow: `scan.py` → `Main.qml.devices` → (`pluginApi.mainInstance`) → `BarWidget`. Config flow: `Settings.qml` → `pluginSettings.devices` → `BarWidget`. + +## Layout & how a plugin is structured + +- `manifest.json` — the contract. `id` must be unique; `entryPoints` maps roles to QML files. Each role is optional; this plugin currently only defines `barWidget`. Other available roles (see the `arch-updater` reference plugin): `main` (background singleton/logic, instantiated once), `panel`, `settings`, `controlCenterWidget`, `desktopWidget`, `launcherProvider`. +- `metadata.defaultSettings` in the manifest defines the plugin's persisted settings schema and defaults. User overrides are merged on top at load. +- Entry-point `.qml` files live at the repo root. `i18n/.json` files provide translations (`en.json` is the fallback); this plugin ships `i18n/en.json` and routes every user-facing string through `pluginApi.tr("dot.path.key")` — no inline literals or post-`tr()` fallbacks (a Noctalia plugin convention; see the repo-root `AGENTS.md`). + +## Installation / runtime model + +The plugin runs from `~/.config/noctalia/plugins//`. During development this is a **symlink** to this repo, so edits here are live. Enablement and source are tracked in `~/.config/noctalia/plugins.json` (`states..enabled`). + +The shell itself runs as `qs -c noctalia-shell`. The Noctalia source (read-only, useful as API reference) is installed at `/etc/xdg/quickshell/noctalia-shell/` — `Commons/`, `Widgets/`, and `Services/` there define everything importable below. + +## Imports available to plugin QML + +```qml +import qs.Commons // singletons: Style, Color, Settings, I18n, Logger, Icons, Time, ShellState +import qs.Widgets // N-prefixed components: NIcon, NText, NButton, NComboBox, NColorChoice, NToggle, ... +import qs.Services.UI // TooltipService, PanelService, BarService (used by BarWidget.qml) +``` + +Service singletons live in subfolders under `Services/` (UI, Networking, Control, Noctalia, SystemInfo, ...). This plugin only uses `qs.Services.UI`; it does **not** read battery data from any service (that comes from `scan.py`). Grep the shell source to find the right import path for any other service. + +## Key conventions (follow these — don't reinvent) + +- **Never hardcode sizes/colors.** Use `Style.*` (`marginS/M`, `barHeight`, `capsuleColor`, `radiusM`, `fontSizeS/M/L`) and `Color.*` (`mPrimary`, `mOnSurface`, `mSurface`, ...) so the widget respects the user's theme and UI scale. `NIcon`/`NText` already apply UI scaling. +- **Icons** are Tabler icon names passed to `NIcon { icon: "..." }` (e.g. `"heart"`); the full set is in `Commons/IconsTabler.qml`. +- **Bar widgets** must declare the properties the loader injects: `screen` (ShellScreen), `widgetId`, `section`, `sectionWidgetIndex`, `sectionWidgetsCount`, plus `property var pluginApi`. + +## The `pluginApi` object + +`PluginService` injects a `pluginApi` into every entry point. It exposes: +- `pluginId`, `pluginDir`, `manifest` +- `pluginSettings` — merged defaults + user settings. Mutate it, then call `saveSettings()` to persist (the call also replaces the object so QML bindings re-fire). +- Panel control: `openPanel(screen, buttonItem)`, `closePanel(screen)`, `togglePanel(...)`, and launcher equivalents. +- i18n: `tr(key, interpolations)`, `trp(key, count, interpolations)`, `hasTranslation(key)`; depend on `translationVersion` to react to language changes. +- Instance references once loaded: `mainInstance`, `barWidget`, `panel`, etc. + +## Where device battery data comes from + +**`scripts/scan.py` only** — not `BluetoothService`, `BatteryService`, or any other Noctalia service. The script probes each supported source (`scan_openrazer()` via the openrazer Python client, `scan_solaar()` by parsing `solaar show`) and prints the normalized JSON array consumed by `Main.qml`. The devices here (Razer dongle, Logitech receiver) aren't Bluetooth, so the shell's Bluetooth/UPower services don't see them — that's the whole reason the plugin shells out to vendor tools. + +## Developing & verifying changes + +There are no unit tests. To see changes: enable Noctalia **debug mode** (`Settings.isDebug`, or launch with `NOCTALIA_DEBUG=1`) — `PluginService` then sets up file watchers and **hot-reloads** the plugin on save. Watch the shell's stdout / `Logger` output (run `qs -c noctalia-shell` from a terminal) for QML errors and plugin load failures, which `PluginService` records in `pluginErrors`. + +**Manifest changes require a full shell restart, not hot reload.** `PluginRegistry.getPluginManifest()` returns an in-memory cache (`installedPlugins`) populated by a disk scan at startup. Hot reload re-instantiates QML against that *cached* manifest, so adding/removing `entryPoints` (or changing `metadata.defaultSettings`) is invisible until the shell restarts. Symptom: a newly-added `main` entry point's `IpcHandler` returns "Target not found". Editing existing `.qml` files hot-reloads fine. + +Smoke test that `Main.qml` loaded: `qs -c noctalia-shell ipc call plugin:battery-wireless-devices refresh` (returns a success string). Verify `scan.py` independently with `python3 scripts/scan.py`. The bar widget renders nothing until the user adds the widget to a bar section **and** enables at least one device in the plugin settings. diff --git a/battery-wireless-devices/LICENSE b/battery-wireless-devices/LICENSE new file mode 100644 index 000000000..9c6c2cdb5 --- /dev/null +++ b/battery-wireless-devices/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 aslauw + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/battery-wireless-devices/Main.qml b/battery-wireless-devices/Main.qml new file mode 100644 index 000000000..7e86fff4d --- /dev/null +++ b/battery-wireless-devices/Main.qml @@ -0,0 +1,86 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons + +// Background logic for the Battery for Wireless Devices plugin. +// +// Polls scripts/scan.py on a timer, parses its normalized JSON, and exposes the +// live device list. The bar widget and settings UI both read `devices` from +// this instance via pluginApi.mainInstance. +Item { + id: root + + // Injected by PluginService. + property var pluginApi: null + + // Live, normalized device list: [{ id, name, type, battery, charging, source }]. + // Replaced wholesale on each successful scan so QML bindings re-fire. + property var devices: [] + + // True while a scan is in flight (used by the settings UI's refresh button). + property bool scanning: false + + // Absolute path to the scan script. pluginDir is a plain filesystem path. + readonly property string scriptPath: (pluginApi ? pluginApi.pluginDir : "") + "/scripts/scan.py" + + readonly property int refreshInterval: { + var s = pluginApi && pluginApi.pluginSettings ? pluginApi.pluginSettings.refreshInterval : undefined + return (typeof s === "number" && s > 0) ? s : 60 + } + + function refresh() { + if (!pluginApi || scanner.running) + return + root.scanning = true + scanner.running = true + } + + Component.onCompleted: refresh() + + Process { + id: scanner + command: ["python3", root.scriptPath] + + property string _buffer: "" + + // scan.py prints the JSON array on a single line; accumulate in case + // the stream is delivered in multiple chunks. + stdout: SplitParser { + onRead: data => scanner._buffer += data + } + + onExited: (exitCode, exitStatus) => { + root.scanning = false + var text = _buffer.trim() + _buffer = "" + if (exitCode !== 0) { + Logger.w("DeviceBattery", "scan.py exited with code", exitCode) + return + } + try { + root.devices = JSON.parse(text) + } catch (e) { + Logger.e("DeviceBattery", "Failed to parse scan output:", e, "raw:", text) + } + } + } + + // Periodic polling. Re-binds automatically when refreshInterval changes. + Timer { + interval: root.refreshInterval * 1000 + running: true + repeat: true + onTriggered: root.refresh() + } + + // `qs -c noctalia-shell ipc call plugin:battery-wireless-devices refresh` + IpcHandler { + target: "plugin:battery-wireless-devices" + + function refresh(): string { + root.refresh() + return "Device battery scan triggered" + } + } +} diff --git a/battery-wireless-devices/README.md b/battery-wireless-devices/README.md new file mode 100644 index 000000000..79cc7c6f0 --- /dev/null +++ b/battery-wireless-devices/README.md @@ -0,0 +1,156 @@ +# Battery for Wireless Devices + +A [Noctalia](https://noctalia.dev) bar widget that shows battery levels for your +connected peripherals — mouse, keyboard, headset, etc. — each as a device icon +inside a circular battery ring. + +![Bar widget](docs/bar.png) + +Battery data is gathered by a small Python script (`scripts/scan.py`) that talks +to vendor tools, so it works for devices the shell's Bluetooth/UPower services +can't see (wireless dongles, USB receivers, ...). + +> **Supported devices:** currently **Razer** (via OpenRazer) and **Logitech** +> (via Solaar). Other sources are easy to add — see +> [Adding a new device source](#adding-a-new-device-source). + +## Features + +- One pill per device: a device icon inside a circular battery ring. +- Ring colour follows the battery level by default (green → amber → red), or pick + a fixed theme colour. The icon colour is configurable too. +- Optional battery percentage and a charging (⚡) indicator. +- Per-device click command (e.g. open Polychromatic or Solaar). +- Drag-and-drop ordering; tooltips; right-click for settings / manual refresh. +- Theme- and scale-aware (uses Noctalia's `Style`/`Color`), works on horizontal + and vertical bars. + +## Requirements + +- **Noctalia** ≥ 4.6.6 +- **python3** +- Per device source (install only what you need): + - **Razer** — [OpenRazer](https://openrazer.github.io/): the running + `openrazer-daemon` and the `openrazer` Python client. + - **Logitech** — [Solaar](https://pwr-solaar.github.io/Solaar/): the `solaar` + command must be on `PATH`. + +The plugin degrades gracefully: if a tool isn't installed, that source simply +reports no devices. + +## Installation + +**From the Noctalia plugin browser (recommended)** + +Open Noctalia's settings → Plugins, find **Battery for Wireless Devices**, and +install it. + +**Manual / development** + +Clone this repository and symlink the plugin folder into Noctalia's plugin +directory (so edits stay live): + +```sh +git clone https://github.com/noctalia-dev/noctalia-plugins +ln -s "$PWD/noctalia-plugins/battery-wireless-devices" \ + ~/.config/noctalia/plugins/battery-wireless-devices +``` + +Then, however you installed it: + +1. Enable **Battery for Wireless Devices** in Noctalia's plugin settings. +2. Restart the shell (`qs -c noctalia-shell`) so the new plugin is registered. +3. Add the **Battery for Wireless Devices** widget to a bar section + (Noctalia Settings → Bar). +4. Open the plugin's settings (see below) and enable the devices you want. + +The bar shows nothing until at least one device is enabled. + +## Usage & settings + +Open the settings from the plugin list, or right-click any of the widget's icons +→ **Settings**. + +![Settings](docs/settings.png) + +**Global options** + +| Option | Description | +| --- | --- | +| Show percentage | Show the battery % next to each icon. | +| Show charging indicator | Add a ⚡ icon next to devices that are charging. | +| Refresh interval | How often to re-scan devices, in seconds. | +| Rescan devices | Trigger an immediate scan and refresh the list. | + +**Per device** (drag the handle on the left to reorder — top = left-most / +top-most in the bar): + +| Option | Description | +| --- | --- | +| Show in bar | Whether this device appears in the bar. | +| Icon | Device icon (or *Auto* to pick one from the device type). | +| Icon color | Theme colour for the icon (*None* = theme foreground). | +| Battery ring color | Theme colour for the ring (*None* = colour by level). | +| Command on click | Shell command run on left-click (e.g. `polychromatic-controller`, `solaar`). | +| 🗑 | Remove the entry (useful for devices you no longer own). | + +Changes apply live; the popup's **Apply** button is redundant. + +You can also trigger a refresh over IPC: + +```sh +qs -c noctalia-shell ipc call plugin:battery-wireless-devices refresh +``` + +## Adding a new device source + +All device discovery lives in **`scripts/scan.py`**. It probes each source and +prints a normalized JSON array on stdout — nothing in the QML needs to change to +support a new source. + +Each device object is: + +```jsonc +{ + "id": ":", // stable across runs (used as the config key) + "name": "Human readable name", + "type": "mouse|keyboard|headset|gamepad|device", // picks the default icon + "battery": 0-100, + "charging": true, + "source": "openrazer|solaar|..." +} +``` + +To add a source: + +1. Write a `scan_()` that returns a list of these dicts. **Swallow all + your own errors** (a missing tool should yield `[]`, never an exception). +2. Append it to the `SOURCES` list at the bottom of the file. + +Verify it independently: + +```sh +python3 scripts/scan.py | python3 -m json.tool +``` + +Pick a **stable** `id` — one that survives reboots and reconnects. Avoid values +that drift: the OpenRazer source keys on USB `vid:pid` rather than the daemon's +serial, which is often an unstable placeholder (e.g. `UNKNOWN_153200B7_0000`). +Detected entries whose `id` drifts are auto-merged in the settings UI by +`source` + `name`, but a stable id is always better. + +## Contributing + +This plugin lives in the +[noctalia-plugins](https://github.com/noctalia-dev/noctalia-plugins) registry — +please open issues and pull requests there. New `scan_()` functions for +other vendors are especially welcome. + +There's no build step or test framework: it's pure QML loaded at runtime by +Noctalia, plus the standalone Python scanner. See [`CLAUDE.md`](CLAUDE.md) for an +overview of the architecture, and the repository-root +[`AGENTS.md`](../AGENTS.md) for plugin conventions and the PR process. + +## License + +[MIT](LICENSE) © aslauw diff --git a/battery-wireless-devices/Settings.qml b/battery-wireless-devices/Settings.qml new file mode 100644 index 000000000..f9c6d9451 --- /dev/null +++ b/battery-wireless-devices/Settings.qml @@ -0,0 +1,512 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +// Plugin-wide settings UI (the "settings" entry point). Rendered inside a +// scroll view by Noctalia's plugin settings popup. Every control applies its +// change live (debounced) via commit(); the popup's Apply button just calls +// saveSettings() -> commit() again, so it's effectively redundant. +ColumnLayout { + id: root + + property var pluginApi: null + property real preferredWidth: 540 + + spacing: Style.marginM + Layout.fillWidth: true + + // Working copy of the per-device config: { : { enabled, icon, iconColor, + // ringColor, launchCmd, name, type } }. Mutated in place by the controls so + // the Repeater never rebuilds and loses edits. + property var editDevices: ({}) + + // Devices shown in the list, in bar order: union of currently-detected + // devices and any previously-configured ones (so unplugged devices keep + // their settings). Reorderable by drag; persisted as deviceOrder. + property var deviceList: [] + + // Geometry for the drag-reorderable, absolutely-positioned card list. + // All cards are the same height, captured from a realized delegate. + property real cardSpacing: Style.marginS + property real cardHeight: 0 + readonly property real cardStride: cardHeight + cardSpacing + + readonly property var liveDevices: (pluginApi && pluginApi.mainInstance) ? (pluginApi.mainInstance.devices || []) : [] + + readonly property var iconOptions: [ + { key: "", name: pluginApi?.tr("settings.icon.auto") }, + { key: "mouse", name: pluginApi?.tr("settings.icon.mouse") }, + { key: "keyboard", name: pluginApi?.tr("settings.icon.keyboard") }, + { key: "headphones", name: pluginApi?.tr("settings.icon.headphones") }, + { key: "device-gamepad", name: pluginApi?.tr("settings.icon.gamepad") }, + { key: "bluetooth", name: pluginApi?.tr("settings.icon.bluetooth") }, + { key: "device-desktop", name: pluginApi?.tr("settings.icon.desktop") }, + { key: "battery", name: pluginApi?.tr("settings.icon.battery") } + ] + + // Notifiable mirror of the global "show percentage" setting. NToggle emits + // toggled(checked) but never updates its own `checked`, so we drive it from + // this property (same reason the per-device toggle uses enabledLocal). + property bool showPercentageEdit: (pluginApi && pluginApi.pluginSettings && pluginApi.pluginSettings.showPercentage === true) + property bool showChargingEdit: (pluginApi && pluginApi.pluginSettings && pluginApi.pluginSettings.showCharging === true) + + // Guards against committing during initial population. + property bool initialized: false + + Component.onCompleted: { + populateList() + initialized = true + } + + function _defaultCfg() { + return { enabled: false, icon: "", iconColor: "none", ringColor: "none", launchCmd: "", name: "", type: "device" } + } + + function _source(id) { + var i = id.indexOf(":") + return i >= 0 ? id.substring(0, i) : id + } + + // Build editDevices + deviceList from saved settings and live detection. + function populateList() { + var saved = (pluginApi && pluginApi.pluginSettings && pluginApi.pluginSettings.devices) ? pluginApi.pluginSettings.devices : {} + var merged = ({}) + + // Seed from saved config (preserves offline devices). + for (var id in saved) { + merged[id] = Object.assign(root._defaultCfg(), saved[id]) + } + // Overlay live detection (updates name/type for known, adds new). + var live = root.liveDevices + var byId = ({}) + for (var i = 0; i < live.length; i++) { + var d = live[i] + byId[d.id] = d + if (!merged[d.id]) + merged[d.id] = root._defaultCfg() + merged[d.id].name = d.name + merged[d.id].type = d.type + } + + // Heal stale duplicates: a saved offline entry that refers to the same + // physical device as a detected one (same source + name, different id — + // e.g. an old name-based id from when the serial was unreadable). Fold + // its config into the detected id, then drop the stale entry. + var changed = false + for (var k = 0; k < live.length; k++) { + var dd = live[k] + for (var mid in merged) { + if (mid === dd.id || byId[mid]) + continue + if (root._source(mid) !== dd.source || merged[mid].name !== dd.name) + continue + if (!saved.hasOwnProperty(dd.id)) { + merged[mid].name = dd.name + merged[mid].type = dd.type + merged[dd.id] = merged[mid] + } + delete merged[mid] + changed = true + } + } + + root.editDevices = merged + + // Build the display list honouring the saved order, appending any + // newly-detected (online first) then offline devices not yet ordered. + var order = (pluginApi && pluginApi.pluginSettings && pluginApi.pluginSettings.deviceOrder) ? pluginApi.pluginSettings.deviceOrder : [] + var seen = ({}) + var list = [] + var addId = function (id) { + if (seen[id] || !merged[id]) + return + seen[id] = true + var on = byId[id] + list.push({ + "id": id, + "name": on ? on.name : (merged[id].name || id), + "type": on ? on.type : (merged[id].type || "device"), + "battery": on ? on.battery : -1, + "charging": on ? on.charging : false, + "online": !!on + }) + } + for (var oi = 0; oi < order.length; oi++) + addId(order[oi]) + for (var j = 0; j < live.length; j++) + addId(live[j].id) + for (var sid in merged) + addId(sid) + root.deviceList = list + + // Persist the dedup so it doesn't reappear. + if (changed && pluginApi) + commit() + } + + // Remove a device entry entirely (e.g. stale offline cruft). A still-present + // device will be re-detected with default config on the next scan. + function removeDevice(id) { + if (root.editDevices[id]) + delete root.editDevices[id] + var nl = [] + for (var i = 0; i < root.deviceList.length; i++) { + if (root.deviceList[i].id !== id) + nl.push(root.deviceList[i]) + } + root.deviceList = nl + commit() + } + + // Reorder the device list (drag-and-drop). Top = left-most in the bar. + function moveDevice(fromIndex, toIndex) { + if (fromIndex === toIndex) + return + if (fromIndex < 0 || fromIndex >= root.deviceList.length) + return + if (toIndex < 0 || toIndex >= root.deviceList.length) + return + var nl = root.deviceList.slice() + var item = nl.splice(fromIndex, 1)[0] + nl.splice(toIndex, 0, item) + root.deviceList = nl + commit() + } + + function set(id, key, value) { + if (!root.editDevices[id]) + root.editDevices[id] = root._defaultCfg() + root.editDevices[id][key] = value + scheduleCommit() + } + + // Coalesce rapid edits (e.g. typing) into a single persist. + function scheduleCommit() { + if (initialized) + commitTimer.restart() + } + + Timer { + id: commitTimer + interval: 200 + onTriggered: root.commit() + } + + // Persist the working state to pluginSettings. Rebuilds devices with fresh + // object identities so the bar widget's bindings re-fire (in-place mutation + // alone wouldn't notify them). + function commit() { + if (!pluginApi) + return + var devices = ({}) + for (var id in root.editDevices) + devices[id] = Object.assign({}, root.editDevices[id]) + var order = [] + for (var j = 0; j < root.deviceList.length; j++) + order.push(root.deviceList[j].id) + pluginApi.pluginSettings.refreshInterval = intervalSpin.value + pluginApi.pluginSettings.showPercentage = root.showPercentageEdit + pluginApi.pluginSettings.showCharging = root.showChargingEdit + pluginApi.pluginSettings.devices = devices + pluginApi.pluginSettings.deviceOrder = order + pluginApi.saveSettings() + } + + // Called by the settings popup's Apply button. + function saveSettings() { + commit() + } + + // ---- Global options ---------------------------------------------------- + + NLabel { + label: pluginApi?.tr("settings.header.label") + description: pluginApi?.tr("settings.header.desc") + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.showPercentage.label") + description: pluginApi?.tr("settings.showPercentage.desc") + checked: root.showPercentageEdit + onToggled: checked => { + root.showPercentageEdit = checked + root.scheduleCommit() + } + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.showCharging.label") + description: pluginApi?.tr("settings.showCharging.desc") + checked: root.showChargingEdit + onToggled: checked => { + root.showChargingEdit = checked + root.scheduleCommit() + } + } + + NSpinBox { + id: intervalSpin + Layout.fillWidth: true + label: pluginApi?.tr("settings.refreshInterval.label") + description: pluginApi?.tr("settings.refreshInterval.desc") + from: 10 + to: 900 + stepSize: 10 + suffix: "s" + value: { + var v = (pluginApi && pluginApi.pluginSettings) ? pluginApi.pluginSettings.refreshInterval : 60 + return (typeof v === "number" && v > 0) ? v : 60 + } + onValueChanged: root.scheduleCommit() + } + + RowLayout { + Layout.fillWidth: true + NButton { + text: pluginApi?.tr("settings.rescan") + icon: "refresh" + onClicked: { + if (pluginApi && pluginApi.mainInstance) + pluginApi.mainInstance.refresh() + rescanTimer.restart() + } + } + Item { Layout.fillWidth: true } + } + + // Re-read the device list shortly after a manual scan completes. + Timer { + id: rescanTimer + interval: 3000 + onTriggered: root.populateList() + } + + NDivider { Layout.fillWidth: true } + + NText { + visible: root.deviceList.length === 0 + text: pluginApi?.tr("settings.noDevices") + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + // ---- Per-device cards (drag the handle to reorder) --------------------- + + Item { + id: cardsContainer + Layout.fillWidth: true + implicitHeight: root.deviceList.length > 0 ? root.deviceList.length * root.cardStride - root.cardSpacing : 0 + + Repeater { + id: cardsRepeater + model: root.deviceList + + delegate: Rectangle { + id: card + required property int index + required property var modelData + readonly property var cfg: root.editDevices[modelData.id] || root._defaultCfg() + // Notifiable mirror of cfg.enabled so the toggle updates live + // (editDevices is mutated in place and doesn't emit change signals). + property bool enabledLocal: cfg.enabled === true + + // Drag state + property bool dragging: false + property int dragStartIndex: -1 + property int dragTargetIndex: -1 + + width: cardsContainer.width + height: cardCol.implicitHeight + Style.marginM * 2 + onHeightChanged: if (root.cardHeight !== height) root.cardHeight = height + + radius: Style.radiusM + color: Color.mSurfaceVariant + border.width: 1 + border.color: dragging ? Color.mPrimary : Color.mOutline + z: dragging ? 10 : 0 + + // Resting position, with neighbours shifting to make room while + // another card is dragged over them. + y: { + if (card.dragging) + return card.y + var draggedIndex = -1 + var targetIndex = -1 + for (var i = 0; i < cardsRepeater.count; i++) { + var it = cardsRepeater.itemAt(i) + if (it && it.dragging) { + draggedIndex = it.dragStartIndex + targetIndex = it.dragTargetIndex + break + } + } + var stride = card.height + root.cardSpacing + if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) { + if (draggedIndex < targetIndex) { + if (card.index > draggedIndex && card.index <= targetIndex) + return (card.index - 1) * stride + } else { + if (card.index >= targetIndex && card.index < draggedIndex) + return (card.index + 1) * stride + } + } + return card.index * stride + } + + Behavior on y { + enabled: !card.dragging + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutQuad + } + } + + ColumnLayout { + id: cardCol + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + // Drag handle + Rectangle { + id: dragHandle + Layout.preferredWidth: Style.baseWidgetSize * 0.7 + Layout.preferredHeight: Style.baseWidgetSize * 0.7 + Layout.alignment: Qt.AlignVCenter + radius: Style.iRadiusXS + color: dragMouse.containsMouse ? Color.mSurface : "transparent" + + ColumnLayout { + anchors.centerIn: parent + spacing: 3 + Repeater { + model: 3 + Rectangle { + Layout.preferredWidth: Style.baseWidgetSize * 0.45 + Layout.preferredHeight: 2 + radius: 1 + color: Color.mOutline + } + } + } + + MouseArea { + id: dragMouse + anchors.fill: parent + hoverEnabled: true + preventStealing: true + cursorShape: Qt.SizeVerCursor + z: 1000 + onPressed: mouse => { + card.dragStartIndex = card.index + card.dragTargetIndex = card.index + card.dragging = true + } + onPositionChanged: mouse => { + if (!card.dragging) + return + var dy = mouse.y - dragHandle.height / 2 + var newY = Math.max(0, Math.min(card.y + dy, cardsContainer.height - card.height)) + card.y = newY + var stride = card.height + root.cardSpacing + var t = Math.floor((newY + card.height / 2) / stride) + card.dragTargetIndex = Math.max(0, Math.min(t, cardsRepeater.count - 1)) + } + onReleased: { + var from = card.dragStartIndex + var to = card.dragTargetIndex + card.dragging = false + card.dragStartIndex = -1 + card.dragTargetIndex = -1 + if (from !== -1 && to !== -1 && from !== to) + root.moveDevice(from, to) + else + root.deviceList = root.deviceList.slice() // rebuild to snap back + } + onCanceled: { + card.dragging = false + card.dragStartIndex = -1 + card.dragTargetIndex = -1 + root.deviceList = root.deviceList.slice() + } + } + } + + NText { + text: card.modelData.name + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + elide: Text.ElideRight + } + NText { + text: card.modelData.online + ? (card.modelData.battery >= 0 ? card.modelData.battery + "%" : "—") + + (card.modelData.charging ? " ⚡" : "") + : pluginApi?.tr("settings.offline") + color: card.modelData.online ? Color.mPrimary : Color.mOnSurfaceVariant + } + + NIconButton { + icon: "trash" + baseSize: Style.baseWidgetSize * 0.8 + tooltipText: pluginApi?.tr("settings.removeDevice") + colorFg: Color.mError + onClicked: root.removeDevice(card.modelData.id) + } + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.showInBar") + checked: card.enabledLocal + onToggled: checked => { + card.enabledLocal = checked + root.set(card.modelData.id, "enabled", checked) + } + } + + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.icon.label") + model: root.iconOptions + currentKey: card.cfg.icon || "" + onSelected: key => root.set(card.modelData.id, "icon", key) + } + + NColorChoice { + Layout.fillWidth: true + label: pluginApi?.tr("settings.iconColor.label") + description: pluginApi?.tr("settings.iconColor.desc") + currentKey: card.cfg.iconColor || "none" + onSelected: key => root.set(card.modelData.id, "iconColor", key) + } + + NColorChoice { + Layout.fillWidth: true + label: pluginApi?.tr("settings.ringColor.label") + description: pluginApi?.tr("settings.ringColor.desc") + currentKey: card.cfg.ringColor || "none" + onSelected: key => root.set(card.modelData.id, "ringColor", key) + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.launchCmd.label") + text: card.cfg.launchCmd || "" + placeholderText: pluginApi?.tr("settings.launchCmd.placeholder") + onTextChanged: root.set(card.modelData.id, "launchCmd", text) + } + } + } + } + } +} diff --git a/battery-wireless-devices/docs/bar.png b/battery-wireless-devices/docs/bar.png new file mode 100644 index 000000000..de9b5cd30 Binary files /dev/null and b/battery-wireless-devices/docs/bar.png differ diff --git a/battery-wireless-devices/docs/settings.png b/battery-wireless-devices/docs/settings.png new file mode 100644 index 000000000..9b01b9750 Binary files /dev/null and b/battery-wireless-devices/docs/settings.png differ diff --git a/battery-wireless-devices/i18n/en.json b/battery-wireless-devices/i18n/en.json new file mode 100644 index 000000000..9ff1791a4 --- /dev/null +++ b/battery-wireless-devices/i18n/en.json @@ -0,0 +1,52 @@ +{ + "menu": { + "settings": "Settings", + "refresh": "Refresh now" + }, + "settings": { + "header": { + "label": "Battery for Wireless Devices", + "desc": "Select which devices appear in the bar and how each looks." + }, + "showPercentage": { + "label": "Show percentage", + "desc": "Display the battery percentage next to each icon." + }, + "showCharging": { + "label": "Show charging indicator", + "desc": "Add a lightning icon next to devices that are charging." + }, + "refreshInterval": { + "label": "Refresh interval", + "desc": "How often to re-scan devices, in seconds." + }, + "rescan": "Rescan devices", + "noDevices": "No devices detected. Make sure the device is on, then press \"Rescan devices\".", + "offline": "offline", + "removeDevice": "Remove this device", + "showInBar": "Show in bar", + "icon": { + "label": "Icon", + "auto": "Auto (by type)", + "mouse": "Mouse", + "keyboard": "Keyboard", + "headphones": "Headphones", + "gamepad": "Gamepad", + "bluetooth": "Bluetooth", + "desktop": "Desktop", + "battery": "Battery" + }, + "iconColor": { + "label": "Icon color", + "desc": "None uses the theme foreground." + }, + "ringColor": { + "label": "Battery ring color", + "desc": "None colours by battery level." + }, + "launchCmd": { + "label": "Command on click", + "placeholder": "e.g. polychromatic-controller / solaar" + } + } +} diff --git a/battery-wireless-devices/manifest.json b/battery-wireless-devices/manifest.json new file mode 100644 index 000000000..0d44317d9 --- /dev/null +++ b/battery-wireless-devices/manifest.json @@ -0,0 +1,28 @@ +{ + "id": "battery-wireless-devices", + "name": "Battery for Wireless Devices", + "version": "1.0.0", + "minNoctaliaVersion": "4.6.6", + "author": "aslauw", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Bar widget showing battery levels for wireless peripherals (mouse, keyboard, headset, etc.) via OpenRazer and Solaar.", + "tags": ["Bar", "Indicator", "System"], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "refreshInterval": 60, + "showPercentage": false, + "showCharging": false, + "devices": {}, + "deviceOrder": [] + } + } +} diff --git a/battery-wireless-devices/preview.png b/battery-wireless-devices/preview.png new file mode 100644 index 000000000..cc54c39bc Binary files /dev/null and b/battery-wireless-devices/preview.png differ diff --git a/battery-wireless-devices/scripts/scan.py b/battery-wireless-devices/scripts/scan.py new file mode 100755 index 000000000..d806affc7 --- /dev/null +++ b/battery-wireless-devices/scripts/scan.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Scan all supported sources for device battery levels and print a normalized +JSON array on stdout. + +Output schema (one object per detected device that reports a battery level): + + { + "id": ":", # stable across runs + "name": "Human readable name", + "type": "mouse|keyboard|headset|...", # used to pick a default icon + "battery": 0-100, + "charging": true|false, + "source": "openrazer|solaar" + } + +To add a new battery source, write a scan_() that returns a list of +dicts in this schema (swallowing all its own errors so a missing tool yields an +empty list), then add it to SOURCES at the bottom. Nothing else needs to change: +Main.qml polls this script, and the settings UI lists whatever it reports. +""" + +import json +import re +import subprocess +import sys + + +def _norm_type(raw: str) -> str: + """Map a vendor 'kind' string onto a small set of known device types.""" + raw = (raw or "").lower() + if "mouse" in raw: + return "mouse" + if "keyboard" in raw or "keypad" in raw: + return "keyboard" + if "headset" in raw or "headphone" in raw: + return "headset" + if "trackball" in raw: + return "mouse" + if "gamepad" in raw or "joystick" in raw: + return "gamepad" + return "device" + + +# --------------------------------------------------------------------------- # +# OpenRazer (mice, keyboards, ... via the running openrazer-daemon) +# --------------------------------------------------------------------------- # +def scan_openrazer(): + devices = [] + try: + from openrazer.client import DeviceManager + except Exception: + return devices + + try: + dm = DeviceManager() + except Exception: + return devices + + for dev in getattr(dm, "devices", []): + try: + if not dev.has("battery"): + continue + # Identify by USB vendor:product id. The daemon's serial is + # unreliable — it frequently reports a placeholder like + # "UNKNOWN_153200B7_0000" (notably when started via the systemd + # user service), which would otherwise mint an unstable id. vid:pid + # is stable regardless and only collides between two identical + # devices. + vid = getattr(dev, "_vid", None) + pid = getattr(dev, "_pid", None) + if vid is not None and pid is not None: + key = "%04x:%04x" % (int(vid), int(pid)) + else: + key = str(dev.serial) + devices.append({ + "id": "openrazer:" + key, + "name": dev.name, + "type": _norm_type(getattr(dev, "type", "")), + "battery": int(round(dev.battery_level)), + "charging": bool(dev.is_charging), + "source": "openrazer", + }) + except Exception: + # One bad device shouldn't drop the rest. + continue + return devices + + +# --------------------------------------------------------------------------- # +# Solaar (Logitech Unifying / Bolt / Lightspeed devices) +# --------------------------------------------------------------------------- # +# Match the aligned top-level fields (a space before the colon) so we don't pick +# up nested lines like "Kind: None" under the DEVICE NAME feature. +_SOLAAR_BATTERY_RE = re.compile(r"Battery:\s*(\d+)%") +_SOLAAR_KIND_RE = re.compile(r"^\s+Kind\s+:\s*(\S.*?)\s*$") +_SOLAAR_SERIAL_RE = re.compile(r"^\s+Serial number\s+:\s*(\S.*?)\s*$") + + +def _slug(name: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + +def scan_solaar(): + devices = [] + try: + out = subprocess.run( + ["solaar", "show"], + capture_output=True, text=True, timeout=30, + ).stdout + except Exception: + return devices + + # `solaar show` prints one block per device/receiver. A new block starts at + # a line with no leading whitespace; lines inside a block are indented. + block = [] + blocks = [] + for line in out.splitlines(): + if line and not line[0].isspace(): + if block: + blocks.append(block) + block = [line] + elif block: + block.append(line) + if block: + blocks.append(block) + + for block in blocks: + text = "\n".join(block) + bm = _SOLAAR_BATTERY_RE.search(text) + if not bm: + continue # receivers and battery-less devices are skipped + name = block[0].strip() + if not name or name.lower().startswith("solaar version"): + continue + + kind = "" + serial = "" + for line in block[1:]: + km = _SOLAAR_KIND_RE.match(line) + if km: + kind = km.group(1) + sm = _SOLAAR_SERIAL_RE.match(line) + if sm and sm.group(1): + serial = sm.group(1) + + key = serial or _slug(name) + devices.append({ + "id": "solaar:" + key, + "name": name, + "type": _norm_type(kind or name), + "battery": int(bm.group(1)), + "charging": "CHARGING" in text and "DISCHARGING" not in text, + "source": "solaar", + }) + return devices + + +SOURCES = [scan_openrazer, scan_solaar] + + +def main(): + results = [] + for scan in SOURCES: + try: + results.extend(scan()) + except Exception: + continue + json.dump(results, sys.stdout) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main()