Add DS4 Color plugin (Hy4ri/ds4-color) (#52)

* feat: add PS4 Colors plugin (DS4 lightbar control)

Wraps the ps4-colors CLI (github.com/Hy4ri/ps4-colors) as a Noctalia
plugin: bar button + color panel (native picker, hex field, presets)
that sets the DualShock 4 lightbar via the ps4-colors binary.

NOTE: thumbnail.webp still pending (generated in noctalia thumbnail
generator) — CI will flag the missing asset until added.

* fix: add PS4 Colors thumbnail (solid crimson card)

960x540 WebP, deep crimson (#990000) to match the plugin accent.

* feat: left-click applies saved color, panel adds Save button

- widget: left-click applies the saved color via service; right-click opens panel
- service: add 'save' IPC (persist without touching controller)
- panel: Save (persist) + Apply (set now) buttons
- translations: add 'save' key

* refactor: implement DS4 protocol in pure Lua (drop C dependency)

The plugin now writes the HID output report to /dev/hidrawN directly via
noctalia.writeFile. USB report 0x05 (32B) and Bluetooth report 0x11 (78B)
with CRC32 are built and the CRC verified against the kernel algorithm.
Device detection scans /sys/class/hidraw uevent for Sony DS4 PIDs.
No external binary or library required.

* fix: panel renders black box (ipairs(nil) at script load)

Move preset-click handler registration into onOpen (after presets are
loaded) instead of module top-level, where ipairs(nil) threw and aborted
the whole panel entry. Guard presets with 'or {}'. Replace unsupported
width='100%' string with a numeric width on the swatch.

* fix: match color_picker panel schema exactly (drop invented props)

Remove border='primary' (unknown value -> whole tree abort) and wrap=true.
Swatch now uses border='outline' + borderWidth to show selection, mirroring
oldirtty/color_picker. Aligns every ui.* node with a known-good panel.

* fix: service dead on load (pluginDataDir nil concat) + real device write

- Resolve pluginDataDir lazily + nil-guard lastFile() so the service entry
  actually loads (top-level concat on nil threw -> no onIpc -> no apply ->
  no notification, which is why the color never changed).
- setLightbar tries noctalia.writeFile, falls back to a python3 O_WRONLY
  binary write for the /dev/hidrawN char device (writeFile is file-oriented).

* fix: replace Luau bitwise ops (&/|/~/>>) with math-only helpers

Noctalia's Lua is strict (PUC-Rio semantic), not Luau: '&', '|', '~', '>>'
are syntax errors -> service failed to load at crc32_init -> no apply.
Added band/rshift/bxor/bnot32 math helpers; CRC32 + BT report now use them
and still produce the verified CRC 523bce6f.

* fix: panel black box (readonly _G) — use static onPreset1..9 globals

Noctalia's _G is immutable at runtime, so assigning _G['onPreset'..i] in
onOpen threw 'attempt to modify a readonly table' and aborted the panel.
Presets are a fixed list, so define onPreset1..9 as plain module globals
calling a shared applyPreset(i). Remove dead makePresetHandler.

* fix: drop unsupported textAlign prop on ui.input (ui-tree warning)

'textAlign' is a label prop, not an input prop; Noctalia ignored it with a
[WRN] ui-tree log line. Input defaults to left align anyway.

* fix: use device-gamepad-2 icon for catalog + bar glyph

'controller' glyph was missing; swap to device-gamepad-2 for both the
plugin catalog icon and the bar widget default glyph.

* rename: PS4 Colors -> DS4 Color (id Hy4ri/ds4-color, dir ds4-color/)

Rename plugin id, display name, bar glyph default, translation key
(ps4_colors -> ds4_color) and all internal IPC references. Drop the stale
ps4_colors_missing string (plugin has no external binary dependency).
Keep accurate comments crediting the original ps4-colors C source.

* ui: shorten panel (height 520->400, tighter gaps/padding, smaller swatch)

Save/Apply buttons were already the last row (bottom of the column); this
just compacts the overall panel height.

* art: use panel screenshot as thumbnail (960x540 webp, crimson bg)

* fix: lowercase author in id (CI validate: author must match ^[a-z0-9][a-z0-9._-]*$)

'Hy4ri' -> 'hy4ri' in id, author, and all internal IPC references.

* fix: thumbnail

* fix(review): declare python3 dependency + document hidraw fallback

Reviewer flagged: plugin declared dependencies=[] but the hidraw write fallback
invokes python3, so systems without Python cannot apply the color if the
direct writeFile fails. Declare 'python3' in dependencies, note it in the
manifest description, and document the requirement in README Requirements.

* security(review): validate color as 6-hex at every trust boundary

Reviewer flagged: last.json value entered shared state unvalidated, then got
single-quote-interpolated into /bin/sh -c via runAsync -> shell injection.

- Add isHex6() (exactly ^[0-9a-fA-F]{6}$) and enforce it at: loadLast
  (poisoned last.json), onSubmitHex, onApply/onSave (from state), onIpc
  apply/save, and the apply()/save() service funcs.
- Since only 6 hex chars can reach runAsync, the single-quote interpolation
  is provably safe (no quote-breaker / metachar can survive).
- Also fixes a latent bug: the old %x regex captured only 5 hex, rejecting
  valid 6-char colors like 990000.

* Update plugin description for clarity

Removed mention of 'hidraw write fallback' from the description.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Lemmy <studio@quadbyte.net>
This commit is contained in:
M57
2026-07-19 08:39:42 -04:00
committed by GitHub
co-authored by M57 Lemmy
parent 7a476bf7b3
commit 5fb0e0f279
7 changed files with 597 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# DS4 Color
Set the LED lightbar colour on a connected PlayStation 4 DualShock 4 controller
from a Noctalia bar button and a color panel. Click the bar icon to apply your
saved colour, or right-click to open the panel and pick a new one.
The entire DualShock 4 output-report protocol (USB report `0x05`, Bluetooth
report `0x11` with CRC32) is implemented in **pure Lua** — no external binary or
library is required. The plugin writes the HID output report straight to
`/dev/hidrawN`.
## Plugin
| Field | Value |
| --- | --- |
| ID | `hy4ri/ds4-color` |
| Entries | Bar widget: `widget`; panel: `panel`; service: `service` |
## Requirements
Requires `python3` (declared in `dependencies`): if Noctalia's `writeFile` cannot
open the `/dev/hidrawN` node `O_WRONLY`, the plugin falls back to a `python3`
one-liner that performs the raw binary write. Systems without Python present cannot
apply the colour when the direct write path fails. The plugin also needs
read/write access to the DualShock 4 `/dev/hidrawN` node.
On most modern Linux desktops with `systemd-logind` and `uaccess`, the device
node is automatically accessible when you are logged in at the seat.
On headless systems or systems without `uaccess`, create a udev rule:
```udev
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="05c4|09cc|0ba0", MODE="0660", GROUP="plugdev"
```
Then add your user to the `plugdev` group:
```sh
sudo usermod -a -G plugdev $USER
```
Re-login or run `udevadm trigger` for the change to take effect.
## Usage
**Left-click** the **DS4 Color** bar button to instantly apply the last saved
colour to every connected DualShock 4. **Right-click** the bar button (or run the
command below) to open the panel:
```sh
noctalia msg panel-toggle hy4ri/ds4-color:panel
```
In the panel: pick a colour with the native picker, type a hex value
(`RRGGBB` / `#RRGGBB` / `0xRRGGBB`), or tap a preset. **Save** persists the
chosen colour (so left-click reapplies it later) without touching the
controller; **Apply** sets the lightbar immediately and also saves it. The saved
colour survives restarts.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `glyph` | `glyph` | `device-gamepad-2` | Icon shown on the bar button. |
## IPC
```sh
noctalia msg plugin hy4ri/ds4-color:service all apply <hex>
noctalia msg plugin hy4ri/ds4-color:service all save <hex>
```
## Notes
- Implements the DualShock 4 HID output report protocol directly in Lua
(derived from the Linux kernel `drivers/hid/hid-playstation.c`): USB report
id `0x05` (32 bytes) and Bluetooth report id `0x11` (78 bytes) with the
required CRC32 checksum. No kernel drivers or external binaries.
- Detects all connected DS4 controllers (USB `0x03` and Bluetooth `0x05` bus)
by scanning `/sys/class/hidraw/*/device/uevent` for Sony VID `054c` and
product IDs `05c4` / `09cc` / `0ba0`.
- The last chosen colour is persisted to the plugin data directory
(`last.json`) and restored on next open. Left-clicking the bar button applies
the saved colour; the panel's **Save** button updates it without writing to
the controller.
- No network access. Only the locally detected DualShock 4 controllers are
touched.
+153
View File
@@ -0,0 +1,153 @@
--!nonstrict
--!nocheck
--!nolint UnknownGlobal
local tr = noctalia.tr("ds4_color")
local SERVICE = "hy4ri/ds4-color:service"
-- Validate a color is exactly 6 hex chars (no metachars / quote-breakers can
-- survive this, so it is safe to interpolate into the runAsync shell string).
local function isHex6(s)
return type(s) == "string" and s:match("^[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]$") ~= nil
end
local function sendToService(event, payload)
local cmd = "noctalia msg plugin " .. SERVICE .. " all " .. event
if payload ~= nil then cmd = cmd .. " '" .. payload .. "'" end
noctalia.runAsync(cmd, function() end)
end
local function currentColor()
return noctalia.state.get("lastColor") or "990000"
end
local function render()
local color = currentColor()
local presets = noctalia.state.get("presets") or {}
local presetRow = {}
for i, p in ipairs(presets) do
local selected = (p.hex:lower() == color:lower())
table.insert(presetRow, ui.box({
width = 34,
height = 34,
fill = "#" .. p.hex,
radius = 8,
border = "outline",
borderWidth = selected and 3 or 1,
onClick = "onPreset" .. i,
}))
end
panel.render(ui.column({ gap = 10, padding = 12 }, {
ui.row({ align = "center", justify = "space_between" }, {
ui.label({ text = tr, fontSize = 18, fontWeight = "bold", color = "primary", flexGrow = 1 }),
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
}),
-- Live swatch preview
ui.box({
width = 200,
height = 72,
fill = "#" .. color,
radius = 12,
border = "outline",
borderWidth = 2,
onClick = "onNativePicker",
}),
-- Hex field + native picker button
ui.row({ gap = 8, align = "center" }, {
ui.input({
key = "hex-input",
value = "#" .. color,
flexGrow = 1,
onSubmit = "onSubmitHex",
}),
ui.button({ glyph = "color-picker", variant = "ghost", onClick = "onNativePicker" }),
}),
ui.label({ text = noctalia.tr("presets"), fontSize = 13, color = "on_surface_variant" }),
ui.row({ gap = 8, align = "center" }, presetRow),
ui.row({ gap = 8 }, {
ui.button({
text = noctalia.tr("save"),
variant = "ghost",
onClick = "onSave",
flexGrow = 1,
}),
ui.button({
text = noctalia.tr("apply"),
variant = "primary",
onClick = "onApply",
flexGrow = 1,
}),
}),
}))
end
--==== callbacks ====
function onApply()
local c = currentColor()
if isHex6(c) then sendToService("apply", c) end
end
function onSave()
local c = currentColor()
if isHex6(c) then sendToService("save", c) end
end
function onSubmitHex(text)
local hex = text:match("^#?([0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])$")
or text:match("^0x([0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])$")
if hex == nil or not isHex6(hex) then
noctalia.notifyError(tr, noctalia.tr("bad_color"))
return
end
noctalia.state.set("lastColor", hex:upper())
render()
end
function onNativePicker()
noctalia.openColorPicker("#" .. currentColor(), function(color)
if color == nil then return end
local hex = color:match("#?(%x%x%x%x%x%x)")
if hex ~= nil then
noctalia.state.set("lastColor", hex:upper())
render()
end
end)
end
function onCloseClicked()
panel.close()
end
function onClose()
end
-- Presets are a fixed list, so define one global handler per index at module
-- load (Noctalia's _G is read-only at runtime — no dynamic _G["onPreset"..i]
-- assignment, which throws "attempt to modify a readonly table" in onOpen).
local function applyPreset(i)
local presets = noctalia.state.get("presets") or {}
local p = presets[i]
if p == nil then return end
noctalia.state.set("lastColor", p.hex:upper())
render()
end
function onPreset1() applyPreset(1) end
function onPreset2() applyPreset(2) end
function onPreset3() applyPreset(3) end
function onPreset4() applyPreset(4) end
function onPreset5() applyPreset(5) end
function onPreset6() applyPreset(6) end
function onPreset7() applyPreset(7) end
function onPreset8() applyPreset(8) end
function onPreset9() applyPreset(9) end
function onOpen(context)
noctalia.state.watch("lastColor", function() render() end)
render()
end
+33
View File
@@ -0,0 +1,33 @@
id = "hy4ri/ds4-color"
name = "DS4 Color"
version = "0.1.0"
plugin_api = 3
author = "hy4ri"
license = "MIT"
icon = "device-gamepad-2"
description = "Set the DualShock 4 lightbar colour from a bar button and panel."
tags = ["gaming", "hardware", "utility", "bar", "panel"]
dependencies = ["python3"]
[[widget]]
id = "widget"
entry = "widget.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
description_key = "settings.glyph.description"
default = "device-gamepad-2"
[[panel]]
id = "panel"
entry = "panel.luau"
width = 420
height = 400
placement = "floating"
position = "center"
[[service]]
id = "service"
entry = "service.luau"
+286
View File
@@ -0,0 +1,286 @@
--!nonstrict
--!nocheck
--!nolint UnknownGlobal
-- Headless service: implements the DualShock 4 lightbar protocol in pure Lua.
-- No external binary needed — writes the HID output report to /dev/hidrawN.
local tr = noctalia.tr("ds4_color")
-- Validate a color is exactly 6 hex chars. With this guarantee, no value that
-- reaches runAsync (via the IPC command string) can contain a quote-breaking or
-- shell metacharacter, so the single-quote interpolation is safe.
local function isHex6(s)
return type(s) == "string" and s:match("^[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]$") ~= nil
end
-- pluginDataDir() may return nil (state home unset), so resolve it lazily and
-- guard every use. Computing it at module top-level and concatenating would
-- throw on nil and kill the whole service entry (no onIpc -> IPC is a no-op).
local function persistentDir()
local dir = noctalia.pluginDataDir()
return dir
end
local function lastFile()
local dir = persistentDir()
if dir == nil then return nil end
return dir .. "/last.json"
end
-- Sony DualShock 4 vendor / product IDs (from ps4-colors detect.c).
local SONY_VID = 0x054C
local KNOWN_PIDS = { [0x05C4] = true, [0x09CC] = true, [0x0BA0] = true }
local BUS_USB = 0x0003
local BUS_BT = 0x0005
-- Report protocol constants (from ps4-colors ds4.c / hid-playstation.c).
local DS4_USB_REPORT_ID = 0x05
local DS4_BT_REPORT_ID = 0x11
local DS4_USB_REPORT_LEN = 32
local DS4_BT_REPORT_LEN = 78
local DS4_USB_COMMON_OFFSET = 1
local DS4_BT_COMMON_OFFSET = 3
local DS4_OUTPUT_VALID_FLAG0_LED = 0x02
local DS4_BT_HW_CONTROL = 0xC4
local PS_OUTPUT_CRC32_SEED = 0xA2
local COMMON_LIGHTBAR_R = 5
local COMMON_LIGHTBAR_G = 6
local COMMON_LIGHTBAR_B = 7
local COMMON_VALID_FLAG0 = 0
-- Built-in presets. Deep crimson is the boss's favourite.
local PRESETS = {
{ name = "Crimson", hex = "990000" },
{ name = "Red", hex = "ff0000" },
{ name = "Green", hex = "00ff00" },
{ name = "Blue", hex = "0000ff" },
{ name = "Cyan", hex = "00ffff" },
{ name = "Magenta", hex = "ff00ff" },
{ name = "Yellow", hex = "ffff00" },
{ name = "White", hex = "ffffff" },
{ name = "Off", hex = "000000" },
}
--==== bitwise helpers (math-only, Lua 5.x safe — no &/|/~/>>) ====
local function band(a, m)
return a % (m + 1)
end
local function rshift(a, n)
return math.floor(a / (2 ^ n))
end
local function bxor(a, b)
local r = 0
local pow = 1
for _ = 1, 32 do
local ab = a % 2
local bb = b % 2
if ab ~= bb then r = r + pow end -- ~= is Lua not-equal, fine
a = math.floor(a / 2)
b = math.floor(b / 2)
pow = pow * 2
end
return r
end
local function bnot32(a)
return bxor(a, 0xFFFFFFFF)
end
--==== CRC32 (poly 0xEDB88320, reflected) ====
local crc32_table = nil
local function crc32_init()
crc32_table = {}
for i = 0, 255 do
local crc = i
for _ = 1, 8 do
if band(crc, 1) ~= 0 then
crc = bxor(rshift(crc, 1), 0xEDB88320)
else
crc = rshift(crc, 1)
end
end
crc32_table[i] = crc
end
end
local function crc32_le(crc, buf, len)
if crc32_table == nil then crc32_init() end
for i = 1, len do
local b = string.byte(buf, i)
crc = bxor(crc32_table[band(bxor(crc, b), 0xFF)], rshift(crc, 8))
end
return crc
end
--==== byte-string helpers ====
local function zeros(n)
return string.rep("\0", n)
end
local function setbyte(s, idx, val)
-- 1-indexed Lua string; idx is 0-based report offset
return string.sub(s, 1, idx) .. string.char(band(val, 0xFF)) .. string.sub(s, idx + 2)
end
--==== report builders ====
local function buildUsbReport(r, g, b)
local buf = "\5" .. zeros(DS4_USB_REPORT_LEN - 1) -- report_id 0x05, rest 0x00
buf = setbyte(buf, DS4_USB_COMMON_OFFSET + COMMON_VALID_FLAG0, DS4_OUTPUT_VALID_FLAG0_LED)
buf = setbyte(buf, DS4_USB_COMMON_OFFSET + COMMON_LIGHTBAR_R, r)
buf = setbyte(buf, DS4_USB_COMMON_OFFSET + COMMON_LIGHTBAR_G, g)
buf = setbyte(buf, DS4_USB_COMMON_OFFSET + COMMON_LIGHTBAR_B, b)
return buf
end
local function buildBtReport(r, g, b)
local buf = "\17\196\0" .. zeros(DS4_BT_REPORT_LEN - 3) -- 0x11, 0xC4, 0x00, rest 0x00
buf = setbyte(buf, DS4_BT_COMMON_OFFSET + COMMON_VALID_FLAG0, DS4_OUTPUT_VALID_FLAG0_LED)
buf = setbyte(buf, DS4_BT_COMMON_OFFSET + COMMON_LIGHTBAR_R, r)
buf = setbyte(buf, DS4_BT_COMMON_OFFSET + COMMON_LIGHTBAR_G, g)
buf = setbyte(buf, DS4_BT_COMMON_OFFSET + COMMON_LIGHTBAR_B, b)
-- CRC32 over bytes 0..73 with seed 0xA2, stored at 74..77 (little-endian).
local crc = crc32_le(0xFFFFFFFF, string.char(PS_OUTPUT_CRC32_SEED), 1)
crc = band(bnot32(crc32_le(crc, buf, DS4_BT_REPORT_LEN - 4)), 0xFFFFFFFF)
buf = setbyte(buf, 74, band(crc, 0xFF))
buf = setbyte(buf, 75, band(rshift(crc, 8), 0xFF))
buf = setbyte(buf, 76, band(rshift(crc, 16), 0xFF))
buf = setbyte(buf, 77, band(rshift(crc, 24), 0xFF))
return buf
end
--==== device detection (port of detect.c) ====
local function parseHidId(line)
-- HID_ID=BBBB:VVVVVVVV:PPPPPPPP
local bus, vendor, product = line:match("HID_ID=(%x+):(%x+):(%x+)")
if bus == nil then return nil end
return tonumber(bus, 16), band(tonumber(vendor, 16), 0xFFFF), band(tonumber(product, 16), 0xFFFF)
end
local function detectControllers()
local out = {}
local hidrawDir = noctalia.listDir("/sys/class/hidraw")
if hidrawDir == nil then return out end
for _, name in ipairs(hidrawDir) do
if name:sub(1, 6) == "hidraw" then
local ueventPath = "/sys/class/hidraw/" .. name .. "/device/uevent"
local content = noctalia.readFile(ueventPath)
if content ~= nil then
local bus, vendor, product
for line in content:gmatch("[^\n]+") do
if line:sub(1, 7) == "HID_ID=" then
bus, vendor, product = parseHidId(line)
break
end
end
if vendor == SONY_VID and KNOWN_PIDS[product] then
table.insert(out, { path = "/dev/" .. name, bus_type = bus })
end
end
end
end
return out
end
-- Write the report to the hidraw node. Noctalia's writeFile is file-oriented and
-- may not open a char device O_WRONLY. If the direct write fails we fall back to
-- python3 (declared in dependencies) which does a true binary O_WRONLY write.
local function setLightbar(dev, r, g, b)
local report = (dev.bus_type == BUS_BT)
and buildBtReport(r, g, b)
or buildUsbReport(r, g, b)
local hex = ""
for i = 1, #report do
hex = hex .. string.format("%02x", string.byte(report, i))
end
local ok, err = noctalia.writeFile(dev.path, report)
if ok then return true, nil end
-- Fallback: raw binary write via python3 (open(path,'wb').write(...)).
local py = string.format(
"python3 -c \"import sys; open('%s','wb').write(bytes.fromhex('%s'))\"",
dev.path, hex)
local done = noctalia.runAsync(py, function(res)
if res == nil or res.exitCode ~= 0 then
noctalia.notifyError(tr, noctalia.tr("write_error") .. " (" .. dev.path .. ")")
end
end)
return done, err
end
local function loadLast()
local path = lastFile()
if path == nil then return "990000" end
local raw = noctalia.readFile(path)
if raw == nil or raw == "" then return "990000" end
local decoded = noctalia.json.decode(raw)
if type(decoded) ~= "table" or type(decoded.hex) ~= "string" then return "990000" end
if not isHex6(decoded.hex) then return "990000" end
return decoded.hex
end
local function saveLast(hex)
local path = lastFile()
if path == nil then return end
noctalia.mkdirAll(persistentDir())
local encoded = noctalia.json.encode({ hex = hex })
if encoded ~= nil then noctalia.writeFile(path, encoded) end
end
local function hexToRgb(hex)
return tonumber(hex:sub(1, 2), 16) or 0,
tonumber(hex:sub(3, 4), 16) or 0,
tonumber(hex:sub(5, 6), 16) or 0
end
-- Apply a hex color to every connected DS4.
local function apply(hex)
if hex == nil or not isHex6(hex) then return end
local r, g, b = hexToRgb(hex)
local devs = detectControllers()
if #devs == 0 then
noctalia.notifyError(tr, noctalia.tr("no_controller"))
return
end
local ok = 0
for _, dev in ipairs(devs) do
local good, err = setLightbar(dev, r, g, b)
if good then
ok = ok + 1
else
noctalia.notifyError(tr, noctalia.tr("write_error") .. " (" .. dev.path .. ")")
end
end
if ok > 0 then
saveLast(hex)
noctalia.state.set("lastColor", hex)
noctalia.notify(tr, noctalia.tr("applied", { color = hex }))
end
end
-- Persist the chosen color WITHOUT touching the controller.
local function save(hex)
if hex == nil or not isHex6(hex) then return end
saveLast(hex)
noctalia.state.set("lastColor", hex)
noctalia.notify(tr, noctalia.tr("saved", { color = hex }))
end
function onIpc(event, payload)
if event == "apply" and payload ~= nil then
local hex = payload:match("^#?([0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])$")
or payload:match("^0x([0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])$")
or ""
apply(hex)
elseif event == "save" and payload ~= nil then
local hex = payload:match("^#?([0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])$")
or payload:match("^0x([0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])$")
or ""
save(hex)
elseif event == "get-presets" then
noctalia.state.set("presets", PRESETS)
end
end
function load()
noctalia.state.set("lastColor", loadLast())
noctalia.state.set("presets", PRESETS)
end
load()
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"ds4_color": "DS4 Color",
"presets": "Presets",
"save": "Save",
"apply": "Apply",
"applied": "Lightbar set to #{color}",
"saved": "Color #{color} saved",
"bad_color": "Invalid hex color.",
"no_controller": "No DualShock 4 detected.",
"write_error": "Failed to write to controller.",
"settings": {
"glyph": {
"label": "Bar Glyph",
"description": "Icon shown on the bar button."
}
}
}
+21
View File
@@ -0,0 +1,21 @@
--!nonstrict
local glyph = noctalia.getConfig("glyph")
local tr = noctalia.tr("ds4_color")
local function render()
barWidget.setGlyph(glyph)
barWidget.setTooltip(tr)
end
-- Left click: apply the saved color directly (no panel needed).
function onClick()
local saved = noctalia.state.get("lastColor") or "990000"
noctalia.runAsync("noctalia msg plugin hy4ri/ds4-color:service all apply '" .. saved .. "'", function() end)
end
-- Right click: open the panel to pick / save a new color.
function onRightClick()
noctalia.togglePanel("hy4ri/ds4-color:panel")
end
render()