Solved. (mostly)
I booted from microsd into CE 22 (up from 21) on that box, the GT King, and there the roku remote works like a million times better (more snappy). No idea why, I had played with the timings a bunch before without any results so it must be something more obscure. Anyway, I was still curious about writing a bpf decoder to utilize the changes in scancode to allow for more rapid presses. I am ashamed to admit this but I had Claude Code take care of it. I’m supposed to hate AI but he wrote it up the logic like 5 minutes, and got the bpf-specific intricacies in order afterward. I couldn’t seem to get it to work on the CE 21 so I booted LibreELEC off of a microsd and was able to load the bpf + toml from there. And it is ridiculous how well it works. It can keep up with sequential button presses about as fast as I can comfortably mash the buttons with one hand. It’s able to exploit the press-bit behavior of the roku remote to emulate the remote protocols that flip a certain bit for each button press for more accuracy. And pressing and holding works too! So then I tried CE 22, expecting it to work great, but it looks like the required kernel config options to facilitate this are not set? Claude says you gotta set CONFIG_RC_CORE=y and CONFIG_BPF_LIRC_MODE2=y. I don’t know about contributing to the CE kernels so I hope a developer can see this and take care of it
. I will probably still have to use libreelec instead, since they have mainline with the composite/cvbs video output, which seems to be missing on CE 22, or at least nothing I tried could get anything more than the Beelink splash screen displayed.
Here’s the code you need, keeping in mind I haven’t touched this.
// SPDX-License-Identifier: CC0-1.0
/*
* roku_nec.bpf.c — BPF IR decoder for Roku TV remotes (NECx protocol)
*
* Roku remotes use NEC-extended encoding but set bit 7 of the command byte
* on hold-repeat frames instead of sending standard NEC repeat codes.
* This causes rc-core to see alternating scancodes (e.g. 0xeac72a vs
* 0xeac7aa) which produces spurious key-up/key-down pairs mid-hold.
*
* This decoder masks out the toggle bit and uses rc-core's toggle mechanism
* to cleanly distinguish new presses from held buttons:
* - bit 7 clear (0x2a) → new press → flip toggle → key_down
* - bit 7 set (0xaa) → hold → same toggle → repeat
*
* Non-Roku NEC remotes are passed through unmodified.
*
* Compile:
* clang --target=bpf -O2 -c roku_nec.bpf.c -o roku_nec.bpf.o
*
* Load (disables built-in decoders, loads this + keymap):
* ir-keytable -c -p roku_nec.bpf.o -w roku.toml -s rc0
*/
typedef unsigned int __u32;
typedef unsigned long long __u64;
/* BPF infrastructure */
#define SEC(name) __attribute__((section(name), used))
/* BPF helper functions (by stable ID) */
static void *(*bpf_map_lookup_elem)(void *map, const void *key) = (void *)1;
static long (*bpf_rc_repeat)(void *ctx) = (void *)77;
static long (*bpf_rc_keydown)(void *ctx, __u32 protocol,
__u64 scancode, __u32 toggle) = (void *)78;
/* RC protocol IDs (enum rc_proto) */
enum {
RC_PROTO_NEC = 9,
RC_PROTO_NECX = 10,
};
/* LIRC mode2 packet encoding */
#define LIRC_PULSE 0x01000000
#define LIRC_SPACE 0x00000000
#define LIRC_TIMEOUT 0x03000000
#define LIRC_OVERFLOW 0x04000000
#define LIRC_TYPE(v) ((v) & 0xFF000000)
#define LIRC_DUR(v) ((v) & 0x00FFFFFF)
/* NEC timing in microseconds (with generous tolerance) */
#define HDR_PULSE_MIN 8000
#define HDR_PULSE_MAX 10000
#define HDR_SPACE_MIN 3500
#define HDR_SPACE_MAX 5500
#define RPT_SPACE_MIN 1500
#define RPT_SPACE_MAX 3000
#define BIT_PULSE_MIN 300
#define BIT_PULSE_MAX 900
#define BIT0_SPACE_MIN 300
#define BIT0_SPACE_MAX 900
#define BIT1_SPACE_MIN 1200
#define BIT1_SPACE_MAX 2200
/* Roku remote address — change this if your remote has a different address */
#define ROKU_ADDR 0xEAC7
/* State machine phases */
enum {
ST_INACTIVE,
ST_HEADER_SPACE,
ST_BIT_PULSE,
ST_BIT_SPACE,
ST_TRAILER,
};
struct decoder_state {
__u32 state;
__u32 bits;
__u32 data;
__u32 toggle; /* flips on each new press, stays same on hold */
};
/* Legacy map definition — ir-keytable has no BTF/.maps support */
struct bpf_map_def {
__u32 type;
__u32 key_size;
__u32 value_size;
__u32 max_entries;
__u32 flags;
__u32 id;
__u32 pinning;
};
struct bpf_map_def SEC("lirc_mode2/maps") decoder_state_map = {
.type = 2, /* BPF_MAP_TYPE_ARRAY */
.key_size = sizeof(__u32),
.value_size = sizeof(struct decoder_state),
.max_entries = 1,
};
SEC("lirc_mode2/roku_nec")
int roku_nec_decode(unsigned int *sample)
{
__u32 val = *sample;
__u32 type = LIRC_TYPE(val);
__u32 dur = LIRC_DUR(val);
__u32 key = 0;
struct decoder_state *s = bpf_map_lookup_elem(&decoder_state_map, &key);
if (!s)
return 0;
/* Reset on timeout or overflow */
if (type == LIRC_TIMEOUT || type == LIRC_OVERFLOW) {
s->state = ST_INACTIVE;
return 0;
}
if (s->state == ST_INACTIVE) {
/* Waiting for 9ms header pulse */
if (type == LIRC_PULSE &&
dur >= HDR_PULSE_MIN && dur <= HDR_PULSE_MAX) {
s->state = ST_HEADER_SPACE;
s->bits = 0;
s->data = 0;
}
} else if (s->state == ST_HEADER_SPACE) {
if (type != LIRC_SPACE) {
s->state = ST_INACTIVE;
} else if (dur >= HDR_SPACE_MIN && dur <= HDR_SPACE_MAX) {
/* 4.5ms — normal header, data follows */
s->state = ST_BIT_PULSE;
} else if (dur >= RPT_SPACE_MIN && dur <= RPT_SPACE_MAX) {
/* 2.25ms — standard NEC repeat code */
bpf_rc_repeat(sample);
s->state = ST_INACTIVE;
} else {
s->state = ST_INACTIVE;
}
} else if (s->state == ST_BIT_PULSE) {
if (type == LIRC_PULSE &&
dur >= BIT_PULSE_MIN && dur <= BIT_PULSE_MAX) {
s->state = ST_BIT_SPACE;
} else {
s->state = ST_INACTIVE;
}
} else if (s->state == ST_BIT_SPACE) {
if (type != LIRC_SPACE) {
s->state = ST_INACTIVE;
} else if (dur >= BIT1_SPACE_MIN && dur <= BIT1_SPACE_MAX) {
/* Bit = 1 */
s->data |= 1u << s->bits;
s->bits++;
s->state = (s->bits == 32) ? ST_TRAILER : ST_BIT_PULSE;
} else if (dur >= BIT0_SPACE_MIN && dur <= BIT0_SPACE_MAX) {
/* Bit = 0 */
s->bits++;
s->state = (s->bits == 32) ? ST_TRAILER : ST_BIT_PULSE;
} else {
s->state = ST_INACTIVE;
}
} else if (s->state == ST_TRAILER) {
s->state = ST_INACTIVE;
/* Expect a final 562µs trailer pulse */
if (type != LIRC_PULSE ||
dur < BIT_PULSE_MIN || dur > BIT_PULSE_MAX)
return 0;
/*
* NEC bit order is LSB-first, accumulated at position 0 upward:
* bits[ 7: 0] = address low byte
* bits[15: 8] = address high byte (or ~addr_lo for standard NEC)
* bits[23:16] = command
* bits[31:24] = ~command
*/
/* Swap address bytes to match kernel NEC scancode convention */
__u32 raw_addr = s->data & 0xFFFF;
__u32 address = ((raw_addr & 0xFF) << 8) | ((raw_addr >> 8) & 0xFF);
__u32 command = (s->data >> 16) & 0xFF;
__u32 cmd_inv = (s->data >> 24) & 0xFF;
/* Validate command/complement pair */
if ((command ^ cmd_inv) != 0xFF)
return 0;
if (address == ROKU_ADDR) {
/*
* Roku remote: bit 7 of command = hold indicator.
* Mask it out so press and hold produce the same scancode.
* Use rc-core toggle to distinguish new press from hold:
* - bit 7 clear → new press → flip toggle
* - bit 7 set → holding → keep toggle (= repeat)
*/
__u32 is_hold = (command >> 7) & 1;
__u32 masked_cmd = command & 0x7F;
__u64 scancode = (__u64)((address << 8) | masked_cmd);
if (!is_hold)
s->toggle ^= 1;
bpf_rc_keydown(sample, RC_PROTO_NECX,
scancode, s->toggle);
} else {
/* Non-Roku NEC remote — standard passthrough */
__u32 addr_lo = address & 0xFF;
__u32 addr_hi = (address >> 8) & 0xFF;
if ((addr_lo ^ addr_hi) == 0xFF) {
/* Standard NEC (8-bit address) */
__u64 sc = (__u64)((addr_lo << 8) | command);
bpf_rc_keydown(sample, RC_PROTO_NEC, sc, 0);
} else {
/* Extended NEC (16-bit address) */
__u64 sc = (__u64)((address << 8) | command);
bpf_rc_keydown(sample, RC_PROTO_NECX, sc, 0);
}
}
} else {
s->state = ST_INACTIVE;
}
return 0;
}
char _license[] SEC("license") = "GPL"; /* required by BPF verifier */
For the button mapping you just use the toml I posted above, except you only need the top half of the scancodes list since bpf is handling the rest now. Also attached is the compiled bpf.o for convenience.
roku_nec.bpf.o (2.0 KB)