Add pulsar-mouse plugin (#318)
* Add pulsar-mouse plugin Battery/charging status and quick sensor/lighting/power controls for a Pulsar gaming mouse (bar widget, desktop/lock-screen widget, and a quick-controls panel), backed by the pulsar-mouse CLI from https://github.com/harveywuk/pulsar-mouse-linux. * pulsar-mouse: sync to v1.6.1 (three panel fixes) The plugin picked up four commits upstream after this PR's initial snapshot was taken. Three of them are bug fixes, two user-facing enough that installing the current snapshot gives a visibly broken panel: - Panel went permanently read-only after a successful profile switch. Every control is `enabled = not busy`, and the profile-switch path set busy = true then relied on readStatus() to clear it, which it did not do on any path. No error was shown, because from the user's point of view nothing had failed. - readStatus() did not guard decoded.dpi being absent, so a malformed response threw before clearing busy - the same lockup by another route. - The panel only re-synced to the mouse's real active profile on the first open of the process, because the profileSynced flag is module- scoped and survived the panel closing. Switching profiles by any other means afterwards (physical profile button, `pulsar-mouse --active-profile`, Fusion on another OS) left every later reopen showing, and writing to, a stale profile. The fourth renames "breath" to "breathe" so the LED effect naming matches the pulsar-mouse CLI's --breathe-speed flag; that accounts for the two README wording changes and part of the panel diff. plugin.toml's version goes 1.6.0 -> 1.6.1 per the contribution rules (patch: fixes and text only, no new capability). Its header comment also now describes the tabbed panel that shipped earlier rather than the original DPI/polling-only one. bar.luau, desktop.luau, translations/en.json and thumbnail.webp are unchanged and untouched. No change to what the plugin shells out to, writes, or reads: still only `pulsar-mouse` (declared in dependencies), still only reading ~/.cache/pulsar-mouse/battery.json. No network access. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d93e7e0b05
commit
73ae491d97
@@ -0,0 +1,185 @@
|
||||
--!nonstrict
|
||||
-- Pulsar Mouse battery/charging status - [[desktop_widget]].
|
||||
--
|
||||
-- Data source: ~/.cache/pulsar-mouse/battery.json, written by
|
||||
-- pulsar-mouse-gui's tray/Home page poll (every 60s while it's running) -
|
||||
-- reading that costs nothing extra on the mouse's wireless link. If that
|
||||
-- file is missing or older than STALE_AFTER (the GUI/tray isn't running),
|
||||
-- falls back to a direct `pulsar-mouse --battery-json` read instead, so the
|
||||
-- widget still works standalone, just with its own USB round-trip.
|
||||
--
|
||||
-- For a wired mouse (no battery to report), --battery-json returns
|
||||
-- {"wireless": false} rather than an error - renders a minimal "Wired"
|
||||
-- placeholder instead of battery info. Unlike bar.luau, there's no
|
||||
-- desktopWidget.setVisible() to hide entirely, so this is the closest
|
||||
-- available equivalent.
|
||||
--
|
||||
-- noctalia.readFile(path) cheap path - reads the GUI's last reading
|
||||
-- noctalia.runAsync(cmd, cb) fallback path - direct CLI read
|
||||
-- noctalia.json.decode(str) both paths return JSON on stdout/in the file
|
||||
-- desktopWidget.render(tree) declarative UI tree
|
||||
|
||||
local STATE_PATH = "~/.cache/pulsar-mouse/battery.json"
|
||||
local STALE_AFTER = 180 -- seconds - 3x the GUI's own 60s poll interval
|
||||
local LOW_POWER_DEFAULT = 15 -- used when the driver has no low-power-threshold
|
||||
-- getter (e.g. nordic.py) or it hasn't been read yet
|
||||
|
||||
local color = noctalia.getConfig("color")
|
||||
local showProgress = noctalia.getConfig("show_progress")
|
||||
local showPercent = noctalia.getConfig("show_percent")
|
||||
local glyphSize = noctalia.getConfig("glyph_size")
|
||||
|
||||
local wireless = true -- assume true until a read says otherwise
|
||||
local percent = nil
|
||||
local charging = false
|
||||
local lowPowerThreshold = nil
|
||||
local errorText = nil
|
||||
local checkingCli = false
|
||||
|
||||
local function statusColor()
|
||||
if errorText ~= nil then
|
||||
return "error"
|
||||
end
|
||||
if charging then
|
||||
return "secondary"
|
||||
end
|
||||
if percent ~= nil and percent <= (lowPowerThreshold or LOW_POWER_DEFAULT) then
|
||||
return "error"
|
||||
end
|
||||
return color
|
||||
end
|
||||
|
||||
local function statusGlyph()
|
||||
local name = (errorText ~= nil or percent == nil) and "mouse-off" or "mouse-filled"
|
||||
return ui.glyph({ key = "glyph", name = name, size = glyphSize, color = statusColor() })
|
||||
end
|
||||
|
||||
local function render()
|
||||
if not wireless then
|
||||
desktopWidget.render(ui.column({ gap = 6, align = "center" }, {
|
||||
ui.glyph({ name = "plug", size = glyphSize, color = "outline" }),
|
||||
ui.label({ text = noctalia.tr("ui.wired"), fontSize = 12, color = "outline" }),
|
||||
}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Every row below gets an explicit, semantic `key` - this list isn't
|
||||
-- structurally stable (the 2nd slot alone can hold an error label, a
|
||||
-- "--" placeholder, or a percent label depending on state; the progress
|
||||
-- row appears/disappears independently), so without a key the reconciler
|
||||
-- matches purely by (type, position) and can attach a stale node to a
|
||||
-- different logical row across renders - same bug class fixed in
|
||||
-- panel.luau, just never applied here.
|
||||
local rows = {
|
||||
statusGlyph(),
|
||||
}
|
||||
|
||||
if errorText ~= nil then
|
||||
-- Diagnostic, not decorative - stays visible even with show_percent off,
|
||||
-- since otherwise a broken setup would just silently show a bare glyph.
|
||||
table.insert(rows, ui.label({ key = "status", text = errorText, fontSize = 12, color = "error" }))
|
||||
elseif showPercent and percent == nil then
|
||||
table.insert(rows, ui.label({ key = "status", text = "--", fontSize = 20, color = "outline" }))
|
||||
elseif showPercent then
|
||||
local text = tostring(percent) .. "%"
|
||||
if charging then
|
||||
text = text .. " " .. noctalia.tr("ui.charging")
|
||||
end
|
||||
table.insert(rows, ui.label({ key = "status", text = text, fontSize = 18, fontWeight = "bold", color = statusColor() }))
|
||||
end
|
||||
|
||||
if showProgress and percent ~= nil then
|
||||
table.insert(rows, ui.progress({
|
||||
key = "progress",
|
||||
progress = percent / 100,
|
||||
fill = statusColor(),
|
||||
width = 120,
|
||||
height = 6,
|
||||
radius = 3,
|
||||
}))
|
||||
end
|
||||
|
||||
desktopWidget.render(ui.column({ gap = 6, align = "center" }, rows))
|
||||
end
|
||||
|
||||
-- Fallback: no fresh state file, so ask the driver directly. Only one of
|
||||
-- these in flight at a time - update() ticks every 30s and a CLI round-trip
|
||||
-- can occasionally take longer than that if the mouse's RF link is asleep.
|
||||
local function readViaCli()
|
||||
if checkingCli then
|
||||
return
|
||||
end
|
||||
if not noctalia.commandExists("pulsar-mouse") then
|
||||
errorText = noctalia.tr("ui.not-installed")
|
||||
render()
|
||||
return
|
||||
end
|
||||
checkingCli = true
|
||||
noctalia.runAsync("pulsar-mouse --battery-json", function(result)
|
||||
checkingCli = false
|
||||
if type(result) ~= "table" or result.timedOut or result.exitCode ~= 0 then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
local decoded = noctalia.json.decode(result.stdout or "")
|
||||
if type(decoded) ~= "table" then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
if decoded.wireless == false then
|
||||
wireless = false
|
||||
errorText = nil
|
||||
render()
|
||||
return
|
||||
end
|
||||
if decoded.battery_percent == nil then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
wireless = true
|
||||
errorText = nil
|
||||
percent = decoded.battery_percent
|
||||
charging = decoded.power_connected == true
|
||||
-- Unlike signal, this has a synchronous getter, so it IS available here
|
||||
-- (nil on a driver without one, e.g. nordic.py - falls back to LOW_POWER_DEFAULT).
|
||||
lowPowerThreshold = decoded.low_power_threshold
|
||||
render()
|
||||
end, 10000)
|
||||
end
|
||||
|
||||
local function readState()
|
||||
local contents = noctalia.readFile(STATE_PATH)
|
||||
if type(contents) ~= "string" or contents == "" then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
local decoded = noctalia.json.decode(contents)
|
||||
if type(decoded) ~= "table" or decoded.battery_percent == nil then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
local age = os.time() - (decoded.updated_at or 0)
|
||||
if age > STALE_AFTER then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
wireless = true
|
||||
errorText = nil
|
||||
percent = decoded.battery_percent
|
||||
charging = decoded.power_connected == true
|
||||
lowPowerThreshold = decoded.low_power_threshold
|
||||
render()
|
||||
end
|
||||
|
||||
function update()
|
||||
noctalia.setUpdateInterval(30000)
|
||||
readState()
|
||||
end
|
||||
|
||||
render()
|
||||
Reference in New Issue
Block a user