diff --git a/decky/armada-control/main.py b/decky/armada-control/main.py index f205c65..30955d3 100644 --- a/decky/armada-control/main.py +++ b/decky/armada-control/main.py @@ -13,9 +13,16 @@ from armada_control.steam import installed_games from armada_control.system import set_ssh_enabled from armada_control.tweaks import save_tweaks +from armada_control.rgb import get_rgb_config, apply_rgb_config, save_rgb_config class Plugin: + async def _main(self): + try: + apply_rgb_config(get_rgb_config()) + except Exception: + pass + # Offload blocking work to a thread so a slow call can't stall Decky's asyncio loop. async def get_config(self): return await asyncio.to_thread(build_config, False) @@ -31,6 +38,10 @@ async def save_tweaks(self, data): await asyncio.to_thread(save_tweaks, data) return await self.get_config() + async def save_rgb(self, data): + await asyncio.to_thread(save_rgb_config, data) + return await self.get_config() + async def set_ssh_enabled(self, enabled): return await asyncio.to_thread(set_ssh_enabled, enabled) diff --git a/decky/armada-control/py_modules/armada_control/config.py b/decky/armada-control/py_modules/armada_control/config.py index 74ba2e2..5477a8d 100644 --- a/decky/armada-control/py_modules/armada_control/config.py +++ b/decky/armada-control/py_modules/armada_control/config.py @@ -3,6 +3,7 @@ from .steam import installed_games from .system import cpu_device_class, os_version, ssh_enabled from .tweaks import fex_profile_labels, load_fex_contract, load_tweaks +from .rgb import get_rgb_config def build_config(include_games=True): @@ -18,4 +19,5 @@ def build_config(include_games=True): "sshEnabled": ssh_enabled(), "controllerType": controller_type(), "controllerTypes": [{"data": key, "label": label} for key, label in CONTROLLER_TYPES.items()], + "rgb": get_rgb_config(), } diff --git a/decky/armada-control/py_modules/armada_control/rgb.py b/decky/armada-control/py_modules/armada_control/rgb.py new file mode 100644 index 0000000..e1a865a --- /dev/null +++ b/decky/armada-control/py_modules/armada_control/rgb.py @@ -0,0 +1,164 @@ +import json +from pathlib import Path +from .system import atomically_write + +RGB_CONFIG = Path("/etc/armada/rgb.json") + +def default_rgb_config(): + return { + "enabled": True, + "sync": True, + "left": {"r": 255, "g": 255, "b": 255, "brightness": 255}, + "right": {"r": 255, "g": 255, "b": 255, "brightness": 255}, + } + +def get_rgb_config(): + try: + if not RGB_CONFIG.exists(): + return default_rgb_config() + config = json.loads(RGB_CONFIG.read_text(encoding="utf-8")) + merged = default_rgb_config() + merged.update(config) + return merged + except (OSError, json.JSONDecodeError): + return default_rgb_config() + +def save_rgb_config(data): + merged = default_rgb_config() + merged.update(data) + atomically_write(RGB_CONFIG, json.dumps(merged, indent=2)) + apply_rgb_config(merged) + return merged + +def discover_leds(): + leds_dir = Path("/sys/class/leds") + if not leds_dir.exists(): + return {} + + # Check for multicolor nodes first (Odin 2 format) + left_multi = leds_dir / "multicolor:left" + right_multi = leds_dir / "multicolor:right" + if left_multi.exists() or right_multi.exists(): + return { + "type": "multicolor", + "left": left_multi, + "right": right_multi + } + + # Otherwise, parse individual single-color channels (Odin 3 format) + channels = { + "type": "individual", + "left": {"r": [], "g": [], "b": []}, + "right": {"r": [], "g": [], "b": []} + } + + for path in leds_dir.iterdir(): + name = path.name.lower() + # Skip unrelated system leds + if name.startswith(("mmc", "backlight", "input", "default")): + continue + + # Determine side (matches 'l:r1', 'left_red', etc.) + side = None + if name.startswith("l:") or "left" in name or name.startswith("l_"): + side = "left" + elif name.startswith("r:") or "right" in name or name.startswith("r_"): + side = "right" + + if not side: + continue + + # Determine color channel + color = None + if ":r" in name or "_r" in name or "red" in name: + color = "r" + elif ":g" in name or "_g" in name or "green" in name: + color = "g" + elif ":b" in name or "_b" in name or "blue" in name: + color = "b" + + if color: + channels[side][color].append(path / "brightness") + + return channels + +def _apply_multicolor_zone(zone_name, zone_data, enabled): + sys_path = Path(f"/sys/class/leds/multicolor:{zone_name}") + if not sys_path.exists(): + return + + brightness_path = sys_path / "brightness" + intensity_path = sys_path / "multi_intensity" + + if not enabled: + if brightness_path.exists(): + try: + brightness_path.write_text("0\n") + except OSError: + pass + return + + if intensity_path.exists(): + try: + r = int(zone_data.get("r", 255)) + g = int(zone_data.get("g", 255)) + b = int(zone_data.get("b", 255)) + intensity_path.write_text(f"{r} {g} {b}\n") + except (OSError, ValueError): + pass + + if brightness_path.exists(): + try: + br = int(zone_data.get("brightness", 255)) + brightness_path.write_text(f"{br}\n") + except (OSError, ValueError): + pass + +def _apply_individual_zone(color_paths, r, g, b, brightness, enabled): + for color, paths in color_paths.items(): + for path in paths: + if not path.exists(): + continue + try: + if not enabled: + path.write_text("0\n") + else: + # Calculate scaled brightness value per channel + val = r if color == "r" else (g if color == "g" else b) + actual_val = int((val * brightness) / 255.0) + path.write_text(f"{actual_val}\n") + except (OSError, ValueError): + pass + +def apply_rgb_config(config): + enabled = config.get("enabled", True) + sync = config.get("sync", True) + left_data = config.get("left", {}) + right_data = config.get("right", {}) + + leds = discover_leds() + if not leds: + return + + if leds["type"] == "multicolor": + _apply_multicolor_zone("left", left_data, enabled) + if sync: + _apply_multicolor_zone("right", left_data, enabled) + else: + _apply_multicolor_zone("right", right_data, enabled) + elif leds["type"] == "individual": + left_r = int(left_data.get("r", 255)) + left_g = int(left_data.get("g", 255)) + left_b = int(left_data.get("b", 255)) + left_brightness = int(left_data.get("brightness", 255)) + + _apply_individual_zone(leds["left"], left_r, left_g, left_b, left_brightness, enabled) + + if sync: + _apply_individual_zone(leds["right"], left_r, left_g, left_b, left_brightness, enabled) + else: + right_r = int(right_data.get("r", 255)) + right_g = int(right_data.get("g", 255)) + right_b = int(right_data.get("b", 255)) + right_brightness = int(right_data.get("brightness", 255)) + _apply_individual_zone(leds["right"], right_r, right_g, right_b, right_brightness, enabled) diff --git a/decky/armada-control/src/Content.tsx b/decky/armada-control/src/Content.tsx index 27eab4d..d9db7a9 100644 --- a/decky/armada-control/src/Content.tsx +++ b/decky/armada-control/src/Content.tsx @@ -1,7 +1,7 @@ import { Field, PanelSection, Tabs } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { getConfig, getInstalledGames, savePowerConfig, saveTweaks } from "./backend"; +import { getConfig, getInstalledGames, savePowerConfig, saveTweaks, saveRgb } from "./backend"; import { useDebouncedSave } from "./hooks/useDebouncedSave"; import { tabIcons } from "./icons"; import { currentGame } from "./lib/games"; @@ -10,6 +10,7 @@ import { styles } from "./styles"; import { Compatibility } from "./tabs/Compatibility"; import { Power } from "./tabs/Power"; import { Settings } from "./tabs/Settings"; +import { Rgb } from "./tabs/Rgb"; import type { Config } from "./types"; export function Content() { @@ -19,6 +20,7 @@ export function Content() { const [message, setMessage] = useState("Loading"); const savedPowerSnapshot = useRef(""); const savedTweaksSnapshot = useRef(""); + const savedRgbSnapshot = useRef(""); const installedGamesRequested = useRef(false); const startupMaintenanceStarted = useRef(false); const load = useCallback(async () => { @@ -26,9 +28,10 @@ export function Content() { const next = await getConfig(); next.game = currentGame(); next.selectedGame = next.game || null; - savedPowerSnapshot.current = JSON.stringify(next.power); - savedTweaksSnapshot.current = JSON.stringify(next.tweaks); - setConfig((current) => ({ ...next, installedGames: current?.installedGames || next.installedGames })); +savedPowerSnapshot.current = JSON.stringify(next.power); +savedTweaksSnapshot.current = JSON.stringify(next.tweaks); +savedRgbSnapshot.current = JSON.stringify(next.rgb); +setConfig((current) => ({ ...next, installedGames: current?.installedGames || next.installedGames })); } catch (error) { setMessage(String(error)); } @@ -97,6 +100,7 @@ export function Content() { }, [!!config]); useDebouncedSave({ config, field: "power", snapshot: savedPowerSnapshot, save: savePowerConfig, setConfig, onError: load }); useDebouncedSave({ config, field: "tweaks", snapshot: savedTweaksSnapshot, save: saveTweaks, setConfig, onError: load }); + useDebouncedSave({ config, field: "rgb", snapshot: savedRgbSnapshot, save: saveRgb, setConfig, onError: load }); if (!config) return ; const tabContent = (content: ReactNode) => (
{content}
@@ -110,6 +114,7 @@ export function Content() { tabs={[ { id: "Compatibility", title: tabIcons.Compatibility, content: tabContent() }, { id: "Power", title: tabIcons.Power, content: tabContent() }, + { id: "Rgb", title: tabIcons.Rgb, content: tabContent() }, { id: "Advanced", title: tabIcons.Advanced, content: tabContent() }, ]} /> diff --git a/decky/armada-control/src/backend.ts b/decky/armada-control/src/backend.ts index 84dd7ec..0dd4be2 100644 --- a/decky/armada-control/src/backend.ts +++ b/decky/armada-control/src/backend.ts @@ -1,10 +1,12 @@ import { call } from "@decky/api"; -import type { CalibrationState, Capture, Config, InstalledGame, PowerConfig, Tweaks } from "./types"; + +import type { CalibrationState, Capture, Config, InstalledGame, PowerConfig, RgbConfig, Tweaks } from "./types"; export const getConfig = () => call<[], Config>("get_config"); export const getInstalledGames = () => call<[], InstalledGame[]>("get_installed_games"); export const savePowerConfig = (data: PowerConfig) => call<[PowerConfig], Config>("save_power_config", data); export const saveTweaks = (data: Tweaks) => call<[Tweaks], Config>("save_tweaks", data); +export const saveRgb = (data: RgbConfig) => call<[RgbConfig], Config>("save_rgb", data); export const setSshEnabled = (enabled: boolean) => call<[boolean], boolean>("set_ssh_enabled", enabled); export const setControllerType = (value: string) => call<[string], string>("set_controller_type", value); export const getControllerState = () => call<[], CalibrationState>("get_controller_state"); diff --git a/decky/armada-control/src/icons.tsx b/decky/armada-control/src/icons.tsx index 2afd895..42dc404 100644 --- a/decky/armada-control/src/icons.tsx +++ b/decky/armada-control/src/icons.tsx @@ -51,4 +51,15 @@ export const tabIcons = { } /> ), + Rgb: ( + + + + + + } + /> + ), }; diff --git a/decky/armada-control/src/tabs/Rgb.tsx b/decky/armada-control/src/tabs/Rgb.tsx new file mode 100644 index 0000000..5af5dfe --- /dev/null +++ b/decky/armada-control/src/tabs/Rgb.tsx @@ -0,0 +1,124 @@ +import { PanelSection, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; +import type { Config, RgbZone } from "../types"; + +export function Rgb({ config, setConfig }: { config: Config; setConfig: (cb: (current: Config | null) => Config | null) => void }) { + const rgb = config.rgb; + + const updateRgb = (updates: Partial) => { + setConfig((current) => { + if (!current) return current; + return { ...current, rgb: { ...current.rgb, ...updates } }; + }); + }; + + const updateZone = (zone: "left" | "right", updates: Partial) => { + setConfig((current) => { + if (!current) return current; + const nextRgb = { + ...current.rgb, + [zone]: { ...current.rgb[zone], ...updates }, + }; + + // If sync is enabled and we're updating the left zone, mirror it to the right zone. + if (current.rgb.sync && zone === "left") { + nextRgb.right = { ...nextRgb.left }; + } + return { ...current, rgb: nextRgb }; + }); + }; + + const renderZoneControls = (zone: "left" | "right", title: string) => { + const data = rgb[zone]; + return ( + + + updateZone(zone, { r: val })} + /> + + + updateZone(zone, { g: val })} + /> + + + updateZone(zone, { b: val })} + /> + + + updateZone(zone, { brightness: val })} + /> + + + ); + }; + + return ( + <> + + + updateRgb({ enabled: val })} + /> + + {rgb.enabled && ( + + { + if (val) { + // When enabling sync, immediately copy left to right + setConfig((current) => { + if (!current) return current; + return { + ...current, + rgb: { + ...current.rgb, + sync: true, + right: { ...current.rgb.left }, + }, + }; + }); + } else { + updateRgb({ sync: false }); + } + }} + /> + + )} + + + {rgb.enabled && ( + <> + {renderZoneControls("left", rgb.sync ? "Color Controls" : "Left Zone")} + {!rgb.sync && renderZoneControls("right", "Right Zone")} + + )} + + ); +} diff --git a/decky/armada-control/src/types.ts b/decky/armada-control/src/types.ts index a9b2f04..c9f3b36 100644 --- a/decky/armada-control/src/types.ts +++ b/decky/armada-control/src/types.ts @@ -70,6 +70,20 @@ export interface GameRef { name: string; } +export interface RgbZone { + r: number; + g: number; + b: number; + brightness: number; +} + +export interface RgbConfig { + enabled: boolean; + sync: boolean; + left: RgbZone; + right: RgbZone; +} + export interface Config { power: PowerConfig; powerDefaults: PowerConfig; @@ -81,6 +95,7 @@ export interface Config { sshEnabled: boolean; controllerType: string; controllerTypes: DropdownChoice[]; + rgb: RgbConfig; calibration?: CalibrationState; game?: GameRef | null; selectedGame?: GameRef | null; diff --git a/system_files/usr/libexec/armada/fake-suspend b/system_files/usr/libexec/armada/fake-suspend index b6ba9d2..ccf4a77 100755 --- a/system_files/usr/libexec/armada/fake-suspend +++ b/system_files/usr/libexec/armada/fake-suspend @@ -230,6 +230,29 @@ unfreeze_processes() { kill -CONT -1 2>/dev/null || true } +suspend_rgb() { + : > "$SAVE_DIR/rgb" + local led previous name + for led in /sys/class/leds/*/brightness; do + [[ -w "$led" ]] || continue + name=$(basename "$(dirname "$led")") + if [[ "$name" =~ multicolor|l:|r:|left|right|joystick ]]; then + previous="$(cat "$led" 2>/dev/null)" || continue + printf '%s\t%s\n' "$led" "$previous" >> "$SAVE_DIR/rgb" + echo 0 > "$led" 2>/dev/null || true + fi + done +} + +resume_rgb() { + [[ -r "$SAVE_DIR/rgb" ]] || return 0 + local led previous + while IFS=$'\t' read -r led previous; do + [[ -w "$led" ]] || continue + echo "$previous" > "$led" 2>/dev/null || true + done < "$SAVE_DIR/rgb" +} + do_suspend() { log "entering fake suspend" mkdir -p "$SAVE_DIR" @@ -237,6 +260,7 @@ do_suspend() { mute_audio block_input display_off # gamescope drm_sleep (game) / bl_power (desktop); after Steam's animation + suspend_rgb freeze_processes # Radio suspend disabled until we can improve resume speed (currently takes 30+ seconds) # suspend_radios @@ -253,6 +277,7 @@ do_resume() { # Radio suspend disabled until we can improve resume speed (currently takes 30+ seconds) # resume_radios unfreeze_processes + resume_rgb unblock_input unmute_audio display_on