Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
274f4ae
feat: add PS4 Colors plugin (DS4 lightbar control)
Hy4ri Jul 18, 2026
fdf9523
fix: add PS4 Colors thumbnail (solid crimson card)
Hy4ri Jul 18, 2026
2b5548c
feat: left-click applies saved color, panel adds Save button
Hy4ri Jul 18, 2026
a5921ae
refactor: implement DS4 protocol in pure Lua (drop C dependency)
Hy4ri Jul 18, 2026
6d49feb
fix: panel renders black box (ipairs(nil) at script load)
Hy4ri Jul 18, 2026
99a2bd3
fix: match color_picker panel schema exactly (drop invented props)
Hy4ri Jul 18, 2026
33aec35
fix: service dead on load (pluginDataDir nil concat) + real device write
Hy4ri Jul 18, 2026
c604e80
fix: replace Luau bitwise ops (&/|/~/>>) with math-only helpers
Hy4ri Jul 18, 2026
a595957
fix: panel black box (readonly _G) — use static onPreset1..9 globals
Hy4ri Jul 18, 2026
a00985d
fix: drop unsupported textAlign prop on ui.input (ui-tree warning)
Hy4ri Jul 18, 2026
ef78e60
fix: use device-gamepad-2 icon for catalog + bar glyph
Hy4ri Jul 18, 2026
f769d15
rename: PS4 Colors -> DS4 Color (id Hy4ri/ds4-color, dir ds4-color/)
Hy4ri Jul 18, 2026
0554dae
ui: shorten panel (height 520->400, tighter gaps/padding, smaller swa…
Hy4ri Jul 18, 2026
b26d328
art: use panel screenshot as thumbnail (960x540 webp, crimson bg)
Hy4ri Jul 18, 2026
033e8f5
fix: lowercase author in id (CI validate: author must match ^[a-z0-9]…
Hy4ri Jul 18, 2026
d16372e
fix: thumbnail
Hy4ri Jul 18, 2026
47b1ec5
fix(review): declare python3 dependency + document hidraw fallback
Hy4ri Jul 19, 2026
01bc188
security(review): validate color as 6-hex at every trust boundary
Hy4ri Jul 19, 2026
8440b0e
Update plugin description for clarity
ItsLemmy Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions ds4-color/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 153 additions & 0 deletions ds4-color/panel.luau
Original file line number Diff line number Diff line change
@@ -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 changes: 33 additions & 0 deletions ds4-color/plugin.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading