Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions decky/armada-control/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions decky/armada-control/py_modules/armada_control/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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(),
}
164 changes: 164 additions & 0 deletions decky/armada-control/py_modules/armada_control/rgb.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 9 additions & 4 deletions decky/armada-control/src/Content.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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() {
Expand All @@ -19,16 +20,18 @@ 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 () => {
try {
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));
}
Expand Down Expand Up @@ -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 <PanelSection title="Armada Control"><Field label={message} /></PanelSection>;
const tabContent = (content: ReactNode) => (
<div className="armada-control-tab-content">{content}</div>
Expand All @@ -110,6 +114,7 @@ export function Content() {
tabs={[
{ id: "Compatibility", title: tabIcons.Compatibility, content: tabContent(<Compatibility config={config} setConfig={setConfig} />) },
{ id: "Power", title: tabIcons.Power, content: tabContent(<Power config={config} setConfig={setConfig} />) },
{ id: "Rgb", title: tabIcons.Rgb, content: tabContent(<Rgb config={config} setConfig={setConfig} />) },
{ id: "Advanced", title: tabIcons.Advanced, content: tabContent(<Settings config={config} setConfig={setConfig} />) },
]}
/>
Expand Down
4 changes: 3 additions & 1 deletion decky/armada-control/src/backend.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
11 changes: 11 additions & 0 deletions decky/armada-control/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,15 @@ export const tabIcons = {
}
/>
),
Rgb: (
<Icon
path={
<>
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.9 1.2 1.5 1.5 2.5" />
<path d="M9 18h6" />
<path d="M10 22h4" />
</>
}
/>
),
};
Loading