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