Skip to content
188 changes: 188 additions & 0 deletions api/xr.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,194 @@ export const flockXR = {
color: "white",
});
},
async 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
?.map((inputSource) => inputSource?.gamepad)
?.filter(Boolean) ?? [];
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) {
console.log("[setControllerLedColor] no gamepad found for index", index);
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) {
console.log("[setControllerLedColor] invalid color format", { color });
return;
}
Comment on lines +95 to +109
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Generator/API default color mismatch can cause silent no-op.

This API only accepts hex at Line [95], but generators/generators-scene.js currently falls back to 'red' when COLOR is missing. That path will always hit invalid-format return at Line [106]-Line [109].

🔧 Suggested cross-file adjustment
-    const color =
-      javascriptGenerator.valueToCode(
-        block,
-        "COLOR",
-        javascriptGenerator.ORDER_NONE,
-      ) || "'red'";
+    const color =
+      javascriptGenerator.valueToCode(
+        block,
+        "COLOR",
+        javascriptGenerator.ORDER_NONE,
+      ) || "'#ff0000'";
🧰 Tools
🪛 GitHub Actions: Prettier

[warning] Prettier reported formatting/style issues.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/xr.js` around lines 95 - 109, The hex-only validator hexToRgb in
api/xr.js rejects named colors (causing setControllerLedColor to no-op when
generators send 'red'); update hexToRgb (or add a new normalizeColor helper used
before calling hexToRgb) to accept common CSS color names (at least 'red',
'green', 'blue', 'black', 'white') by mapping them to their hex equivalents, or
use a small parsing fallback that converts named colors to hex before parseInt
runs, then ensure the call site that logs "[setControllerLedColor] invalid color
format" uses the normalized value so generators/generators-scene.js can continue
to pass names without causing silent failures.

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",
);

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, usagePage: 0x01, usage: 0x05 }],
});
device = requested?.[0];
}
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)
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] = 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 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 {
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;
}

await device.sendReport(ds4ReportId, ds4Data);
return true;
} catch (error) {
console.log("[setControllerLedColor] WebHID fallback error", error);
return false;
}
},
exportMesh(meshName, format) {
//meshName = "scene";

Expand Down
27 changes: 27 additions & 0 deletions blocks/xr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
};
}
2 changes: 2 additions & 0 deletions flock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -1108,6 +1109,7 @@ export const flock = {
"setSky",
"setFog",
"setCameraBackground",
"setControllerLedColor",
"lightIntensity",
"lightColor",
"create3DText",
Expand Down
17 changes: 17 additions & 0 deletions generators/generators-scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `await setControllerLedColor(${controllerIndex}, ${color});\n`;
};
}
3 changes: 3 additions & 0 deletions locale/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions locale/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading