diff --git a/ds4-color/README.md b/ds4-color/README.md new file mode 100644 index 0000000..73792a6 --- /dev/null +++ b/ds4-color/README.md @@ -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 +noctalia msg plugin hy4ri/ds4-color:service all save +``` + +## 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. diff --git a/ds4-color/panel.luau b/ds4-color/panel.luau new file mode 100644 index 0000000..f9e5430 --- /dev/null +++ b/ds4-color/panel.luau @@ -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 diff --git a/ds4-color/plugin.toml b/ds4-color/plugin.toml new file mode 100644 index 0000000..f241eec --- /dev/null +++ b/ds4-color/plugin.toml @@ -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" diff --git a/ds4-color/service.luau b/ds4-color/service.luau new file mode 100644 index 0000000..fac3b22 --- /dev/null +++ b/ds4-color/service.luau @@ -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() diff --git a/ds4-color/thumbnail.webp b/ds4-color/thumbnail.webp new file mode 100644 index 0000000..7fc8b23 Binary files /dev/null and b/ds4-color/thumbnail.webp differ diff --git a/ds4-color/translations/en.json b/ds4-color/translations/en.json new file mode 100644 index 0000000..949591a --- /dev/null +++ b/ds4-color/translations/en.json @@ -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." + } + } +} diff --git a/ds4-color/widget.luau b/ds4-color/widget.luau new file mode 100644 index 0000000..33a1f2d --- /dev/null +++ b/ds4-color/widget.luau @@ -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()