Skip to content
Draft
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
7 changes: 7 additions & 0 deletions config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
# true: Xbox Elite (Steam paddles), false: standard gamepad + base remaps
emulate_elite = true

[led]
mode = "static" # static / off / breathing
brightness = 80
color = "#00ff00" # green

[gyro]
mode = "off" # off / mouse / joystick

Expand All @@ -24,6 +29,7 @@ gyro = { mode = "mouse", sensitivity = 2.0 }
stick_left = { mode = "scroll" } # gamepad / mouse / scroll
stick_right = { mode = "mouse", sensitivity = 1.0 } # gamepad / mouse
dpad = { mode = "arrows" } # gamepad / arrows / scroll
led = { mode = "breathing", color = "#ff0000" } # red breathing when aiming
remap = { RB = "mouse_left", RT = "mouse_right", RM = "mouse_middle", A = "KEY_LEFTMETA" }

# Hold RM for stick mouse + arrows
Expand All @@ -32,4 +38,5 @@ trigger = "RM"
hold_timeout = 150
stick_right = { mode = "mouse", sensitivity = 1.5 }
dpad = { mode = "arrows" }
led = { mode = "static", color = "#0000ff" } # blue when in mouse mode
remap = { A = "mouse_left", B = "mouse_right" }
9 changes: 9 additions & 0 deletions include/vader5/config.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ struct DpadConfig {
Mode mode{Gamepad};
};

struct LedConfig {
enum Mode { Off, Static, Breathing };
Mode mode{Static};
uint8_t brightness{80};
uint8_t r{0}, g{255}, b{0};
};

struct LayerConfig {
enum Activation { Hold, Toggle };
std::string name;
Expand All @@ -52,6 +59,7 @@ struct LayerConfig {
std::optional<StickConfig> stick_left;
std::optional<StickConfig> stick_right;
std::optional<DpadConfig> dpad;
std::optional<LedConfig> led;
std::unordered_map<std::string, RemapTarget> remap;
};

Expand All @@ -63,6 +71,7 @@ struct Config {
StickConfig left_stick;
StickConfig right_stick;
DpadConfig dpad;
LedConfig led;
std::unordered_map<std::string, LayerConfig> layers;

static auto load(const std::string& path) -> Result<Config>;
Expand Down
3 changes: 3 additions & 0 deletions include/vader5/gamepad.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ class Gamepad {
void process_layer_buttons(const GamepadState& state, const GamepadState& prev);
void process_base_remaps(const GamepadState& state, const GamepadState& prev);
void update_tap_hold(const GamepadState& state, const GamepadState& prev);
void update_led();
void emit_tap(const RemapTarget& tap);
auto send_led(const LedConfig& led) -> bool;
auto get_active_layer() -> const LayerConfig*;
static auto is_button_pressed(const GamepadState& state, std::string_view name) -> bool;

Expand Down Expand Up @@ -75,6 +77,7 @@ class Gamepad {
uint8_t suppressed_ext_{0};
uint16_t prev_suppressed_buttons_{0};
uint8_t prev_suppressed_ext_{0};
const LayerConfig* prev_layer_{nullptr};
};

} // namespace vader5
43 changes: 43 additions & 0 deletions openspec/changes/led-control/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# LED/Lighting Control

## Why

Vader 5 Pro has RGB lighting. Protocol documented in research/protocol.md.

## What Changes

- Add LED config to config.toml (base + per-layer)
- Implement LED control via HID command `a9 17`
- Initial modes: static, off, breathing

## Config Example

```toml
[led]
mode = "static" # static / off / breathing
brightness = 80
color = "#00ff00"

[layer.aim]
trigger = "LM"
led = { mode = "breathing", color = "#ff0000" }

[layer.mouse]
trigger = "RM"
led = { mode = "static", color = "#0000ff" }
```

Layer LED changes when layer activates, reverts when deactivated.

## Dynamic Control Interface

Unix socket `/run/vader5.sock` for external LED control:

```json
{"cmd": "led", "mode": "static", "color": "#ff0000", "brightness": 100}
```

Use cases:
- Game integration (health bar → LED color)
- Music visualization
- Notifications
30 changes: 30 additions & 0 deletions openspec/changes/led-control/specs/led/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## ADDED Requirements

### Requirement: LED Configuration

The driver SHALL support LED configuration via `[led]` section.

#### Scenario: Static LED mode
- **WHEN** config contains `[led]` with `mode = "static"`
- **THEN** LED displays solid color at specified brightness

#### Scenario: LED off
- **WHEN** config contains `mode = "off"`
- **THEN** LED is disabled

#### Scenario: Breathing mode
- **WHEN** config contains `mode = "breathing"`
- **THEN** LED pulses between off and specified color

### Requirement: Per-Layer LED

The driver SHALL support per-layer LED configuration.

#### Scenario: Layer LED override
- **WHEN** layer contains `led = { mode = "...", color = "..." }`
- **AND** layer activates
- **THEN** LED changes to layer config

#### Scenario: Layer LED revert
- **WHEN** layer with LED config deactivates
- **THEN** LED reverts to base config
32 changes: 32 additions & 0 deletions openspec/changes/led-control/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# LED Control Implementation Tasks

## Phase 1: Core LED Support

- [x] Add LedConfig struct to config.hpp (mode, brightness, color)
- [x] Parse `[led]` section in config.cpp
- [x] Add LED state to Gamepad class
- [x] Implement HID command `a9 17` for LED control
- [x] Send LED command on startup
- [ ] **Verify LED protocol works on hardware** (blocked: no visible effect)

## Phase 2: Per-Layer LED

- [x] Add optional led field to LayerConfig
- [x] Apply layer LED on activation
- [x] Revert to base LED on deactivation
- [ ] **Verify layer LED switching works** (depends on Phase 1 verification)

## Phase 3: Dynamic Control (Optional)

- [ ] Create Unix socket listener
- [ ] Parse JSON commands
- [ ] Apply LED changes from socket

## Notes

Protocol debugging needed:
- Command `a9 17` implemented per research/protocol.md
- Sending mode packet (byte[4]=00) + color layer packet (byte[4]=01)
- Using GRB color format as documented
- hidraw write returns success but no visible LED change
- Need to verify: timing, packet order, or undocumented requirements
61 changes: 61 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,59 @@ auto parse_dpad_mode(std::string_view mode) -> DpadConfig::Mode {
return mode == "arrows" ? DpadConfig::Arrows : DpadConfig::Gamepad;
}

auto parse_led_mode(std::string_view mode) -> LedConfig::Mode {
if (mode == "off") {
return LedConfig::Off;
}
if (mode == "breathing") {
return LedConfig::Breathing;
}
return LedConfig::Static;
}

auto parse_hex_color(std::string_view color, uint8_t& r, uint8_t& g, uint8_t& b) -> bool {
if (color.size() != 7 || color[0] != '#') {
return false;
}
auto hex = [](char c) -> int {
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + 10;
}
if (c >= 'A' && c <= 'F') {
return c - 'A' + 10;
}
return -1;
};
const int rh = hex(color[1]);
const int rl = hex(color[2]);
const int gh = hex(color[3]);
const int gl = hex(color[4]);
const int bh = hex(color[5]);
const int bl = hex(color[6]);
if (rh < 0 || rl < 0 || gh < 0 || gl < 0 || bh < 0 || bl < 0) {
return false;
}
r = static_cast<uint8_t>((rh << 4) | rl);
g = static_cast<uint8_t>((gh << 4) | gl);
b = static_cast<uint8_t>((bh << 4) | bl);
return true;
}

void parse_led(const toml::table& tbl, LedConfig& cfg) {
if (const auto* val = tbl["mode"].as_string()) {
cfg.mode = parse_led_mode(val->get());
}
if (const auto* val = tbl["brightness"].as_integer()) {
cfg.brightness = static_cast<uint8_t>(std::clamp<int64_t>(val->get(), 0, 100));
}
if (const auto* val = tbl["color"].as_string()) {
parse_hex_color(val->get(), cfg.r, cfg.g, cfg.b);
}
}

void parse_gyro(const toml::table& tbl, GyroConfig& cfg) {
if (const auto* val = tbl["mode"].as_string()) {
cfg.mode = parse_gyro_mode(val->get());
Expand Down Expand Up @@ -121,6 +174,11 @@ auto parse_layer(const std::string& name, const toml::table& tbl) -> LayerConfig
parse_dpad(*sub, dc);
layer.dpad = dc;
}
if (const auto* sub = tbl["led"].as_table()) {
LedConfig lc;
parse_led(*sub, lc);
layer.led = lc;
}
if (const auto* sub = tbl["remap"].as_table()) {
for (const auto& [key, node] : *sub) {
if (const auto* str = node.as_string()) {
Expand Down Expand Up @@ -219,6 +277,9 @@ auto Config::load(const std::string& path) -> Result<Config> {
if (const auto* dpad_tbl = tbl["dpad"].as_table()) {
parse_dpad(*dpad_tbl, cfg.dpad);
}
if (const auto* led_tbl = tbl["led"].as_table()) {
parse_led(*led_tbl, cfg.led);
}

if (const auto* layer_tbl = tbl["layer"].as_table()) {
for (const auto& [name, node] : *layer_tbl) {
Expand Down
87 changes: 86 additions & 1 deletion src/gamepad.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ auto apply_curve(float value, float curve, float deadzone = 0.0F) -> float {

constexpr uint8_t CMD_TEST_MODE = 0x11;
constexpr uint8_t CMD_RUMBLE = 0x12;
constexpr uint8_t CMD_LED = 0xa9;
constexpr uint8_t CMD_LED_SUB = 0x17;
constexpr uint8_t LED_MODE_STATIC = 0x05;
constexpr uint8_t LED_MODE_OFF = 0x06;
constexpr uint8_t LED_MODE_BREATHING = 0x02;
constexpr uint8_t LED_CYCLE_TIME = 30;
constexpr size_t LED_MODE_BYTE = 9;
constexpr size_t LED_CYCLE_BYTE = 10;
constexpr size_t LED_BRIGHT_BYTE = 11;
constexpr size_t LED_PARAM_BYTE = 13;
constexpr uint8_t CHECKSUM_TEST_ON = 0x15;
constexpr uint8_t CHECKSUM_TEST_OFF = 0x14;

Expand Down Expand Up @@ -185,7 +195,9 @@ auto Gamepad::open(const Config& cfg) -> Result<Gamepad> {
input = std::move(*dev);
}

return Gamepad(std::move(*hid), std::move(*uinput), std::move(input), cfg);
auto gp = Gamepad(std::move(*hid), std::move(*uinput), std::move(input), cfg);
gp.send_led(cfg.led);
return gp;
}

auto Gamepad::is_button_pressed(const GamepadState& state, std::string_view name) -> bool {
Expand Down Expand Up @@ -634,6 +646,7 @@ auto Gamepad::poll() -> Result<void> {
suppressed_ext_ = 0;

update_tap_hold(*state, prev_state_);
update_led();
process_gyro(*state);
process_mouse_stick(*state);
process_scroll_stick(*state);
Expand Down Expand Up @@ -674,6 +687,78 @@ auto Gamepad::send_rumble(uint8_t left, uint8_t right) -> bool {
return hidraw_.write(pkt).has_value();
}

auto Gamepad::send_led(const LedConfig& led) -> bool {
std::array<uint8_t, PKT_SIZE> pkt{};

// Mode config (byte[4]=00)
pkt.at(0) = MAGIC_5A;
pkt.at(1) = MAGIC_A5;
pkt.at(2) = CMD_LED;
pkt.at(3) = CMD_LED_SUB;
pkt.at(4) = 0x00;
pkt.at(LED_MODE_BYTE) = (led.mode == LedConfig::Breathing) ? 0x01 : 0x00;
pkt.at(LED_CYCLE_BYTE) = LED_CYCLE_TIME;
pkt.at(LED_BRIGHT_BYTE) = led.brightness;
if (led.mode == LedConfig::Off) {
pkt.at(LED_PARAM_BYTE) = LED_MODE_OFF;
} else if (led.mode == LedConfig::Breathing) {
pkt.at(LED_PARAM_BYTE) = LED_MODE_BREATHING;
} else {
pkt.at(LED_PARAM_BYTE) = LED_MODE_STATIC;
}

std::cerr << "[LED] pkt1: ";
for (size_t i = 0; i < 16; ++i) {
std::cerr << std::hex << static_cast<int>(pkt[i]) << " ";
}
std::cerr << std::dec << "\n";

auto res1 = hidraw_.write(pkt);
std::cerr << "[LED] write1: " << (res1 ? "ok" : "fail") << "\n";
if (!res1) {
return false;
}

// Color layer 1 (byte[4]=01, GRB at bytes 5-7)
pkt.fill(0);
pkt.at(0) = MAGIC_5A;
pkt.at(1) = MAGIC_A5;
pkt.at(2) = CMD_LED;
pkt.at(3) = CMD_LED_SUB;
pkt.at(4) = 0x01;
pkt.at(5) = led.g;
pkt.at(6) = led.r;
pkt.at(7) = led.b;

std::cerr << "[LED] pkt2: ";
for (size_t i = 0; i < 16; ++i) {
std::cerr << std::hex << static_cast<int>(pkt[i]) << " ";
}
std::cerr << std::dec << "\n";

auto res2 = hidraw_.write(pkt);
std::cerr << "[LED] write2: " << (res2 ? "ok" : "fail") << "\n";
if (!res2) {
return false;
}

return true;
}

void Gamepad::update_led() {
const auto* layer = get_active_layer();
if (layer == prev_layer_) {
return;
}
prev_layer_ = layer;

if (layer != nullptr && layer->led) {
send_led(*layer->led);
} else {
send_led(config_.led);
}
}

Gamepad::~Gamepad() {
send_test_mode(hidraw_, false);
}
Expand Down
Loading