From 075dfaea344050fa5354a88445612694a1f9a5eb Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Thu, 30 Apr 2026 20:39:15 +0100 Subject: [PATCH 1/8] Add XR block to set controller LED color by index --- api/xr.js | 35 ++++++++++++++++++++++++++++++++++ blocks/xr.js | 27 ++++++++++++++++++++++++++ generators/generators-scene.js | 17 +++++++++++++++++ locale/en.js | 3 +++ locale/es.js | 3 +++ toolbox.js | 23 ++++++++++++++++++++++ 6 files changed, 108 insertions(+) diff --git a/api/xr.js b/api/xr.js index 45cb3d587..5541f66f2 100644 --- a/api/xr.js +++ b/api/xr.js @@ -51,6 +51,41 @@ export const flockXR = { color: "white", }); }, + setControllerLedColor(controllerIndex, color) { + const gamepads = + typeof navigator?.getGamepads === "function" ? navigator.getGamepads() : []; + const gamepad = gamepads?.[Math.trunc(Number(controllerIndex))]; + + if (!gamepad) return; + + const hexToRgb = (hex) => { + const trimmed = String(hex).trim(); + const match = trimmed.match(/^#?([0-9a-fA-F]{6})$/); + if (!match) return null; + const value = match[1]; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; + }; + + const rgb = hexToRgb(color); + if (!rgb) return; + + try { + if (typeof gamepad.lightIndicator?.setColor === "function") { + gamepad.lightIndicator.setColor(rgb.r, rgb.g, rgb.b); + return; + } + + if (typeof gamepad.leds?.[0]?.setColor === "function") { + gamepad.leds[0].setColor(rgb.r, rgb.g, rgb.b); + } + } catch { + // silent by design + } + }, exportMesh(meshName, format) { //meshName = "scene"; diff --git a/blocks/xr.js b/blocks/xr.js index db7c1b7e1..10a587613 100644 --- a/blocks/xr.js +++ b/blocks/xr.js @@ -175,4 +175,31 @@ export function defineXRBlocks() { this.setStyle("scene_blocks"); }, }; + + Blockly.Blocks["set_controller_led_color"] = { + init: function () { + this.jsonInit({ + type: "set_controller_led_color", + message0: translate("set_controller_led_color"), + args0: [ + { + type: "input_value", + name: "CONTROLLER_INDEX", + check: "Number", + }, + { + type: "input_value", + name: "COLOR", + check: "Colour", + }, + ], + previousStatement: null, + nextStatement: null, + colour: categoryColours["Scene"], + tooltip: getTooltip("set_controller_led_color"), + }); + this.setHelpUrl(getHelpUrlFor(this.type)); + this.setStyle("scene_blocks"); + }, + }; } diff --git a/generators/generators-scene.js b/generators/generators-scene.js index 4871d258c..14a7ed372 100644 --- a/generators/generators-scene.js +++ b/generators/generators-scene.js @@ -675,4 +675,21 @@ export function registerSceneGenerators(javascriptGenerator) { // Generate the code that calls the helper function return `exportMesh(${meshVar}, "${format}");\n`; }; + + javascriptGenerator.forBlock["set_controller_led_color"] = function (block) { + const controllerIndex = + javascriptGenerator.valueToCode( + block, + "CONTROLLER_INDEX", + javascriptGenerator.ORDER_NONE, + ) || "0"; + const color = + javascriptGenerator.valueToCode( + block, + "COLOR", + javascriptGenerator.ORDER_NONE, + ) || '"#ffffff"'; + + return `setControllerLedColor(${controllerIndex}, ${color});\n`; + }; } diff --git a/locale/en.js b/locale/en.js index 9614fc0ec..2cea4866e 100644 --- a/locale/en.js +++ b/locale/en.js @@ -338,6 +338,7 @@ export default { controller_rumble: "rumble %1 motor at strength %2 for %3 ms", controller_rumble_pattern: "rumble %1 motor strength %2 on %3 ms off %4 ms %5 times", + set_controller_led_color: "set controller %1 LED color to %2", // Blockly message overrides for English LISTS_CREATE_WITH_INPUT_WITH: "list", @@ -648,6 +649,8 @@ export default { "Make a connected game controller rumble. Choose all, left, or right motor, set the strength (0 to 1), and how long to rumble in milliseconds.\nKeyword: rumble", controller_rumble_pattern_tooltip: "Make a connected game controller rumble in a repeating pattern. Set the motor, strength (0 to 1), on time, off time, and number of repeats.\nKeyword: rumble pattern", + set_controller_led_color_tooltip: + "Set the LED color for a connected game controller by index. If LED control is unsupported, this block does nothing.\nKeyword: controller led", // Dropdown option translations AWAIT_option: "await", diff --git a/locale/es.js b/locale/es.js index f38527099..f6e7c0a4a 100644 --- a/locale/es.js +++ b/locale/es.js @@ -337,6 +337,7 @@ export default { controller_rumble: "vibrar motor %1 con fuerza %2 durante %3 ms", // human controller_rumble_pattern: "vibrar %1 fuerza de motor %2 encendido %3 ms apagado %4 ms %5 veces", // human + set_controller_led_color: "establecer color del LED del mando %1 a %2", // human // Blockly message overrides for English LISTS_CREATE_WITH_INPUT_WITH: "lista", // human @@ -663,6 +664,8 @@ export default { "Hace vibrar un mando conectado. Elige todos, el izquierdo o el derecho motor, establece la fuerza (0 a 1) y cuánto tiempo para vibrar en milisegundos.\nPalabra clave: vibrar", // human controller_rumble_pattern_tooltip: "Hace vibrar un mando conectado en un patrón repetido. Establece el motor, la fuerza (0 a 1), el tiempo encendido, el tiempo apagado y el número de repeticiones.\nPalabra clave: patrón de vibrar", // human + set_controller_led_color_tooltip: + "Establece el color del LED de un mando conectado por índice. Si el control de LED no es compatible, este bloque no hace nada.\nPalabra clave: led del mando", // human // Dropdown option translations AWAIT_option: "esperar", // human diff --git a/toolbox.js b/toolbox.js index 5454716e8..ee8fc509f 100644 --- a/toolbox.js +++ b/toolbox.js @@ -607,6 +607,29 @@ const toolboxSceneXR = { type: "export_mesh", keyword: "export", }, + { + kind: "block", + type: "set_controller_led_color", + keyword: "controller led", + inputs: { + CONTROLLER_INDEX: { + shadow: { + type: "math_number", + fields: { + NUM: 0, + }, + }, + }, + COLOR: { + shadow: { + type: "colour", + fields: { + COLOR: "#ffffff", + }, + }, + }, + }, + }, /*{ kind: "block", type: "play_rumble_pattern", From 718680f8157f714f3fb7aba9b558c530bb0d4ccb Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Thu, 30 Apr 2026 20:47:47 +0100 Subject: [PATCH 2/8] Expose setControllerLedColor in sandbox API bindings --- flock.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flock.js b/flock.js index c884e5564..509376dcd 100644 --- a/flock.js +++ b/flock.js @@ -998,6 +998,7 @@ export const flock = { cameraControl: this.cameraControl?.bind(this), setCameraBackground: this.setCameraBackground?.bind(this), setXRMode: this.setXRMode?.bind(this), + setControllerLedColor: this.setControllerLedColor?.bind(this), applyForce: this.applyForce?.bind(this), moveByVector: this.moveByVector?.bind(this), glideTo: this.glideTo?.bind(this), @@ -1108,6 +1109,7 @@ export const flock = { "setSky", "setFog", "setCameraBackground", + "setControllerLedColor", "lightIntensity", "lightColor", "create3DText", From 82afdbb5f267d8e0002eb21d1021a212b975e925 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Thu, 30 Apr 2026 21:03:07 +0100 Subject: [PATCH 3/8] Use XR input sources when setting controller LED color --- api/xr.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/api/xr.js b/api/xr.js index 5541f66f2..6e0db6b9a 100644 --- a/api/xr.js +++ b/api/xr.js @@ -52,9 +52,16 @@ export const flockXR = { }); }, setControllerLedColor(controllerIndex, color) { - const gamepads = - typeof navigator?.getGamepads === "function" ? navigator.getGamepads() : []; - const gamepad = gamepads?.[Math.trunc(Number(controllerIndex))]; + const index = Math.max(0, Math.trunc(Number(controllerIndex))); + const xrGamepads = + flock.xrHelper?.baseExperience?.input?.inputSources + ?.map((inputSource) => inputSource?.gamepad) + ?.filter(Boolean) ?? []; + const browserGamepads = Array.from(navigator?.getGamepads?.() ?? []).filter( + Boolean, + ); + + const gamepad = xrGamepads[index] ?? browserGamepads[index]; if (!gamepad) return; From a6b340a79fd4781c4c47b429bdbc84c42176e289 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Thu, 30 Apr 2026 21:20:19 +0100 Subject: [PATCH 4/8] Add temporary debug logging for controller LED API --- api/xr.js | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/api/xr.js b/api/xr.js index 6e0db6b9a..b07e2d763 100644 --- a/api/xr.js +++ b/api/xr.js @@ -52,6 +52,7 @@ export const flockXR = { }); }, setControllerLedColor(controllerIndex, color) { + console.log("[setControllerLedColor] called", { controllerIndex, color }); const index = Math.max(0, Math.trunc(Number(controllerIndex))); const xrGamepads = flock.xrHelper?.baseExperience?.input?.inputSources @@ -60,10 +61,34 @@ export const flockXR = { const browserGamepads = Array.from(navigator?.getGamepads?.() ?? []).filter( Boolean, ); + console.log("[setControllerLedColor] gamepad pools", { + index, + xrGamepadCount: xrGamepads.length, + browserGamepadCount: browserGamepads.length, + xrGamepads: xrGamepads.map((gp) => ({ + id: gp?.id, + mapping: gp?.mapping, + })), + browserGamepads: browserGamepads.map((gp) => ({ + id: gp?.id, + mapping: gp?.mapping, + })), + }); const gamepad = xrGamepads[index] ?? browserGamepads[index]; + console.log("[setControllerLedColor] selected gamepad", { + index, + selectedFrom: xrGamepads[index] ? "xrGamepads" : "browserGamepads", + gamepadId: gamepad?.id, + hasLightIndicator: typeof gamepad?.lightIndicator?.setColor === "function", + hasLedsArray: Array.isArray(gamepad?.leds), + hasFirstLedSetter: typeof gamepad?.leds?.[0]?.setColor === "function", + }); - if (!gamepad) return; + if (!gamepad) { + console.log("[setControllerLedColor] no gamepad found for index", index); + return; + } const hexToRgb = (hex) => { const trimmed = String(hex).trim(); @@ -78,18 +103,29 @@ export const flockXR = { }; const rgb = hexToRgb(color); - if (!rgb) return; + if (!rgb) { + console.log("[setControllerLedColor] invalid color format", { color }); + return; + } + console.log("[setControllerLedColor] parsed color", { color, rgb }); try { if (typeof gamepad.lightIndicator?.setColor === "function") { gamepad.lightIndicator.setColor(rgb.r, rgb.g, rgb.b); + console.log("[setControllerLedColor] used lightIndicator.setColor"); return; } if (typeof gamepad.leds?.[0]?.setColor === "function") { gamepad.leds[0].setColor(rgb.r, rgb.g, rgb.b); + console.log("[setControllerLedColor] used leds[0].setColor"); + return; } + console.log( + "[setControllerLedColor] no supported LED API on selected gamepad", + ); } catch { + console.log("[setControllerLedColor] LED set failed"); // silent by design } }, From 865e56221bd99570e393e047928bb3e547c0ac77 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Thu, 30 Apr 2026 21:34:49 +0100 Subject: [PATCH 5/8] Add WebHID fallback for PlayStation controller LED control --- api/xr.js | 66 +++++++++++++++++++++++++++++++++- generators/generators-scene.js | 2 +- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/api/xr.js b/api/xr.js index b07e2d763..c63e3769c 100644 --- a/api/xr.js +++ b/api/xr.js @@ -51,7 +51,7 @@ export const flockXR = { color: "white", }); }, - setControllerLedColor(controllerIndex, color) { + async setControllerLedColor(controllerIndex, color) { console.log("[setControllerLedColor] called", { controllerIndex, color }); const index = Math.max(0, Math.trunc(Number(controllerIndex))); const xrGamepads = @@ -124,11 +124,75 @@ export const flockXR = { console.log( "[setControllerLedColor] no supported LED API on selected gamepad", ); + + const hidResult = await this.setPlayStationControllerLedViaHid?.( + gamepad, + rgb, + ); + if (hidResult) { + console.log("[setControllerLedColor] used WebHID fallback"); + } else { + console.log("[setControllerLedColor] WebHID fallback unavailable/failed"); + } } catch { console.log("[setControllerLedColor] LED set failed"); // silent by design } }, + async setPlayStationControllerLedViaHid(gamepad, rgb) { + try { + if (!navigator?.hid) return false; + const id = String(gamepad?.id ?? ""); + const vendorMatch = id.match(/Vendor:\s*([0-9a-fA-F]{4})/); + const productMatch = id.match(/Product:\s*([0-9a-fA-F]{4})/); + const vendorId = vendorMatch ? parseInt(vendorMatch[1], 16) : null; + const productId = productMatch ? parseInt(productMatch[1], 16) : null; + if (vendorId !== 0x054c || !productId) return false; + + const paired = navigator.hid + .getDevices() + .then((devices) => + devices.find( + (d) => d.vendorId === vendorId && d.productId === productId, + ), + ); + let device = await paired; + if (!device) { + const requested = await navigator.hid.requestDevice({ + filters: [{ vendorId, productId }], + }); + device = requested?.[0]; + } + if (!device) return false; + if (!device.opened) await device.open(); + + if (productId === 0x09cc || productId === 0x0ce6) { + const reportId = 0x02; + const data = new Uint8Array(48); + data[0] = 0x02; + data[1] = 0x03; + data[2] = 0x00; + data[45] = rgb.r; + data[46] = rgb.g; + data[47] = rgb.b; + await device.sendReport(reportId, data); + return true; + } + + const ds4ReportId = 0x05; + const ds4Data = new Uint8Array(32); + ds4Data[0] = 0x05; + ds4Data[1] = 0xff; + ds4Data[6] = rgb.r; + ds4Data[7] = rgb.g; + ds4Data[8] = rgb.b; + await device.sendReport(ds4ReportId, ds4Data); + return true; + } catch (error) { + console.log("[setControllerLedColor] WebHID fallback error", error); + return false; + } + }, exportMesh(meshName, format) { //meshName = "scene"; diff --git a/generators/generators-scene.js b/generators/generators-scene.js index 14a7ed372..c27c1cac8 100644 --- a/generators/generators-scene.js +++ b/generators/generators-scene.js @@ -690,6 +690,6 @@ export function registerSceneGenerators(javascriptGenerator) { javascriptGenerator.ORDER_NONE, ) || '"#ffffff"'; - return `setControllerLedColor(${controllerIndex}, ${color});\n`; + return `await setControllerLedColor(${controllerIndex}, ${color});\n`; }; } From 5d0dbd502a22a7567889980aa45b12635da30a45 Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Thu, 30 Apr 2026 21:42:52 +0100 Subject: [PATCH 6/8] Try multiple DualSense HID report formats for LED writes --- api/xr.js | 56 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/api/xr.js b/api/xr.js index c63e3769c..751f4cf3b 100644 --- a/api/xr.js +++ b/api/xr.js @@ -167,16 +167,52 @@ export const flockXR = { if (!device.opened) await device.open(); if (productId === 0x09cc || productId === 0x0ce6) { - const reportId = 0x02; - const data = new Uint8Array(48); - data[0] = 0x02; - data[1] = 0x03; - data[2] = 0x00; - data[45] = rgb.r; - data[46] = rgb.g; - data[47] = rgb.b; - await device.sendReport(reportId, data); - return true; + const usbData = new Uint8Array(47); + usbData[0] = 0x02; // valid flag 0 + usbData[1] = 0x03; // valid flag 1 (lightbar + player leds) + usbData[44] = rgb.r; + usbData[45] = rgb.g; + usbData[46] = rgb.b; + + const btData = new Uint8Array(77); + btData[0] = 0x02; // valid flag 0 + btData[1] = 0x03; // valid flag 1 + btData[2] = 0x00; // valid flag 2 + btData[45] = rgb.r; + btData[46] = rgb.g; + btData[47] = rgb.b; + + const outputReportIds = new Set( + device.collections.flatMap((collection) => + (collection.outputReports || []).map((report) => report.reportId), + ), + ); + console.log("[setControllerLedColor] DualSense output report IDs", [ + ...outputReportIds, + ]); + + const attempts = [ + { reportId: 0x02, data: usbData, label: "dualsense-usb-0x02" }, + { reportId: 0x31, data: btData, label: "dualsense-bt-0x31" }, + ]; + + for (const attempt of attempts) { + try { + await device.sendReport(attempt.reportId, attempt.data); + console.log( + "[setControllerLedColor] WebHID sendReport success", + attempt.label, + ); + return true; + } catch (error) { + console.log( + "[setControllerLedColor] WebHID sendReport failed", + attempt.label, + error, + ); + } + } + return false; } const ds4ReportId = 0x05; From ea0eb59cda85f5ca734cf84d2bb2da46405be07a Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Thu, 30 Apr 2026 21:53:47 +0100 Subject: [PATCH 7/8] Fix HID payloads to exclude report ID bytes for PS controllers --- api/xr.js | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/api/xr.js b/api/xr.js index 751f4cf3b..5438589dc 100644 --- a/api/xr.js +++ b/api/xr.js @@ -159,7 +159,7 @@ export const flockXR = { let device = await paired; if (!device) { const requested = await navigator.hid.requestDevice({ - filters: [{ vendorId, productId }], + filters: [{ vendorId, productId, usagePage: 0x01, usage: 0x05 }], }); device = requested?.[0]; } @@ -168,19 +168,21 @@ export const flockXR = { if (productId === 0x09cc || productId === 0x0ce6) { const usbData = new Uint8Array(47); - usbData[0] = 0x02; // valid flag 0 - usbData[1] = 0x03; // valid flag 1 (lightbar + player leds) - usbData[44] = rgb.r; - usbData[45] = rgb.g; - usbData[46] = rgb.b; + usbData[0] = 0x03; // valid flag 1 (lightbar + player leds) + usbData[1] = 0x00; // motor right + usbData[2] = 0x00; // motor left + usbData[43] = rgb.r; + usbData[44] = rgb.g; + usbData[45] = rgb.b; const btData = new Uint8Array(77); - btData[0] = 0x02; // valid flag 0 - btData[1] = 0x03; // valid flag 1 - btData[2] = 0x00; // valid flag 2 - btData[45] = rgb.r; - btData[46] = rgb.g; - btData[47] = rgb.b; + btData[0] = 0x03; // valid flag 1 + btData[1] = 0x00; // valid flag 2 + btData[2] = 0x00; // motor right + btData[3] = 0x00; // motor left + btData[44] = rgb.r; + btData[45] = rgb.g; + btData[46] = rgb.b; const outputReportIds = new Set( device.collections.flatMap((collection) => @@ -216,12 +218,13 @@ export const flockXR = { } const ds4ReportId = 0x05; - const ds4Data = new Uint8Array(32); - ds4Data[0] = 0x05; - ds4Data[1] = 0xff; - ds4Data[6] = rgb.r; - ds4Data[7] = rgb.g; - ds4Data[8] = rgb.b; + const ds4Data = new Uint8Array(31); + ds4Data[0] = 0xff; + ds4Data[1] = 0x00; + ds4Data[2] = 0x00; + ds4Data[5] = rgb.r; + ds4Data[6] = rgb.g; + ds4Data[7] = rgb.b; await device.sendReport(ds4ReportId, ds4Data); return true; } catch (error) { From cf2b3dea40a33bcbd3a973ef5f25e57057796acf Mon Sep 17 00:00:00 2001 From: Dr Tracy Gardner Date: Thu, 30 Apr 2026 22:03:18 +0100 Subject: [PATCH 8/8] Try DS4-compatible report when DualSense exposes only report ID 0x05 --- api/xr.js | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/api/xr.js b/api/xr.js index 5438589dc..9e0051aa7 100644 --- a/api/xr.js +++ b/api/xr.js @@ -166,6 +166,22 @@ export const flockXR = { if (!device) return false; if (!device.opened) await device.open(); + const outputReportIds = new Set( + device.collections.flatMap((collection) => + (collection.outputReports || []).map((report) => report.reportId), + ), + ); + console.log("[setControllerLedColor] output report IDs", [...outputReportIds]); + + const ds4ReportId = 0x05; + const ds4Data = new Uint8Array(31); + ds4Data[0] = 0xff; + ds4Data[1] = 0x00; + ds4Data[2] = 0x00; + ds4Data[5] = rgb.r; + ds4Data[6] = rgb.g; + ds4Data[7] = rgb.b; + if (productId === 0x09cc || productId === 0x0ce6) { const usbData = new Uint8Array(47); usbData[0] = 0x03; // valid flag 1 (lightbar + player leds) @@ -184,19 +200,18 @@ export const flockXR = { btData[45] = rgb.g; btData[46] = rgb.b; - const outputReportIds = new Set( - device.collections.flatMap((collection) => - (collection.outputReports || []).map((report) => report.reportId), - ), - ); - console.log("[setControllerLedColor] DualSense output report IDs", [ - ...outputReportIds, - ]); - - const attempts = [ + const attempts = []; + if (outputReportIds.has(0x05)) { + attempts.push({ + reportId: 0x05, + data: ds4Data, + label: "dualsense-ds4compat-0x05", + }); + } + attempts.push( { reportId: 0x02, data: usbData, label: "dualsense-usb-0x02" }, { reportId: 0x31, data: btData, label: "dualsense-bt-0x31" }, - ]; + ); for (const attempt of attempts) { try { @@ -217,14 +232,6 @@ export const flockXR = { return false; } - const ds4ReportId = 0x05; - const ds4Data = new Uint8Array(31); - ds4Data[0] = 0xff; - ds4Data[1] = 0x00; - ds4Data[2] = 0x00; - ds4Data[5] = rgb.r; - ds4Data[6] = rgb.g; - ds4Data[7] = rgb.b; await device.sendReport(ds4ReportId, ds4Data); return true; } catch (error) {