Skip to content
Merged
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
156 changes: 96 additions & 60 deletions crates/hypercolor-hal/src/drivers/lianli/wireless/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ struct WirelessState {
topology_frozen: bool,
streaming_started: bool,
clock_sent: bool,
/// The last submitted pixels, including black frames, retained across idle upkeep.
/// None means no lighting frame has arrived in this session.
latest_colors: Option<Vec<[u8; 3]>>,
}

impl WirelessState {
Expand Down Expand Up @@ -415,6 +418,75 @@ impl WirelessControllerProtocol {
commands.push(Self::tx_command(stream_prep_packet(index, channel), false));
}
}

/// Encode live delivery and PWM recovery through the same per-cluster wire path.
fn push_rgb_frame(
state: &WirelessState,
colors: &[[u8; 3]],
frame_number: Option<u32>,
buffer: &mut CommandBuffer<'_>,
) {
let master = Self::master_mac(state);
let mut offset = 0_usize;
for cluster in &state.table.clusters {
let leds_per_fan = usize::from(cluster.model.leds_per_fan());
let led_count = usize::from(cluster.fan_count) * leds_per_fan;
let mut raw = Vec::with_capacity(led_count * 3);
for led in 0..led_count {
let color = colors.get(offset + led).copied().unwrap_or([0, 0, 0]);
raw.extend_from_slice(&color);
}
offset += led_count;
if cluster.right_attach {
raw = reverse_fan_order(&raw, leds_per_fan, usize::from(cluster.fan_count));
}

let transfer = rgb_transfer(
cluster.mac,
master,
effect_index_for(&raw),
u8::try_from(led_count).unwrap_or(u8::MAX),
LIVE_TOTAL_FRAMES,
LIVE_INTERVAL_MS,
&raw,
);
if let Some(frame_number) = frame_number
&& frame_number.is_multiple_of(FRAME_TRACE_EVERY)
{
trace!(
frame_number,
mac = %format_mac(cluster.mac),
led_count,
data_envelopes = transfer.data.len(),
first_pixel = ?raw.get(..3),
"wireless frame encoded"
);
}
for repeat in 0..HEADER_REPEATS {
let tail_delay = if repeat + 1 < HEADER_REPEATS {
HEADER_REPEAT_GAP
} else {
SLICE_PACING
};
Self::push_envelope(
buffer,
&transfer.header,
cluster.channel,
cluster.rx_type,
tail_delay,
);
}
for envelope in &transfer.data {
Self::push_envelope(
buffer,
envelope,
cluster.channel,
cluster.rx_type,
SLICE_PACING,
);
}
}
}
}

impl Protocol for WirelessControllerProtocol {
Expand Down Expand Up @@ -478,7 +550,17 @@ impl Protocol for WirelessControllerProtocol {
fn encode_frame_into(&self, colors: &[[u8; 3]], commands: &mut Vec<ProtocolCommand>) {
let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
state.topology_frozen = true;
let master = Self::master_mac(&state);
let led_count = state
.table
.clusters
.iter()
.map(FanCluster::led_count)
.sum::<u32>();
let led_count = usize::try_from(led_count).unwrap_or(0);
let latest_colors = state.latest_colors.get_or_insert_with(Vec::new);
latest_colors.clear();
latest_colors.extend_from_slice(&colors[..colors.len().min(led_count)]);
latest_colors.resize(led_count, [0, 0, 0]);
let mut buffer = CommandBuffer::new(commands);

if !state.streaming_started {
Expand All @@ -497,64 +579,7 @@ impl Protocol for WirelessControllerProtocol {
}

let frame_number = self.frames_encoded.fetch_add(1, Ordering::Relaxed);
let mut offset = 0_usize;
for cluster in &state.table.clusters {
let leds_per_fan = usize::from(cluster.model.leds_per_fan());
let led_count = usize::from(cluster.fan_count) * leds_per_fan;
let mut raw = Vec::with_capacity(led_count * 3);
for led in 0..led_count {
let color = colors.get(offset + led).copied().unwrap_or([0, 0, 0]);
raw.extend_from_slice(&color);
}
offset += led_count;
if cluster.right_attach {
raw = reverse_fan_order(&raw, leds_per_fan, usize::from(cluster.fan_count));
}

let transfer = rgb_transfer(
cluster.mac,
master,
effect_index_for(&raw),
u8::try_from(led_count).unwrap_or(u8::MAX),
LIVE_TOTAL_FRAMES,
LIVE_INTERVAL_MS,
&raw,
);
if frame_number.is_multiple_of(FRAME_TRACE_EVERY) {
trace!(
frame_number,
mac = %format_mac(cluster.mac),
led_count,
data_envelopes = transfer.data.len(),
first_pixel = ?raw.get(..3),
"wireless frame encoded"
);
}
for repeat in 0..HEADER_REPEATS {
let tail_delay = if repeat + 1 < HEADER_REPEATS {
HEADER_REPEAT_GAP
} else {
SLICE_PACING
};
Self::push_envelope(
&mut buffer,
&transfer.header,
cluster.channel,
cluster.rx_type,
tail_delay,
);
}
for envelope in &transfer.data {
Self::push_envelope(
&mut buffer,
envelope,
cluster.channel,
cluster.rx_type,
SLICE_PACING,
);
}
}

Self::push_rgb_frame(&state, colors, Some(frame_number), &mut buffer);
buffer.finish();
}

Expand All @@ -566,7 +591,8 @@ impl Protocol for WirelessControllerProtocol {
}

/// The 1 Hz upkeep: refresh the table, hold every cluster at the PWM it
/// reported, and broadcast the clock. Streaming mode is armed here only
/// reported, restore the latest RGB frame, and broadcast the clock.
/// Streaming mode is armed here only
/// when no frame has done it yet.
fn keepalive_commands(&self) -> Vec<ProtocolCommand> {
let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
Expand All @@ -592,6 +618,16 @@ impl Protocol for WirelessControllerProtocol {
Self::envelope_commands(&mut commands, &envelope, cluster.channel, cluster.rx_type);
}

// PWM traffic can interrupt the receiver's RGB playback. Restore the
// submitted frame before clock, pairing, or the next actor turn can delay it.
if let Some(colors) = &state.latest_colors {
let mut rgb_commands = Vec::new();
let mut buffer = CommandBuffer::new(&mut rgb_commands);
Self::push_rgb_frame(&state, colors, None, &mut buffer);
buffer.finish();
commands.extend(rgb_commands);
}

let payload = clock_payload(WallClock::now_local());
let envelope = clock_sync_envelope(master, &payload, !state.clock_sent);
Self::envelope_commands(&mut commands, &envelope, channel, RF_BROADCAST_SLOT);
Expand Down
131 changes: 128 additions & 3 deletions crates/hypercolor-hal/tests/lianli_wireless_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use hypercolor_hal::drivers::lianli::wireless::frame::{
clock_sync_envelope, pwm_envelope, reverse_fan_order, rgb_transfer,
};
use hypercolor_hal::drivers::lianli::wireless::tinyuz;
use hypercolor_hal::protocol::{Protocol, ResponseTolerance, TransferType};
use hypercolor_hal::protocol::{Protocol, ProtocolCommand, ResponseTolerance, TransferType};
use hypercolor_hal::registry::TransportType;
use hypercolor_types::device::DeviceTopologyHint;

Expand Down Expand Up @@ -513,9 +513,134 @@ fn upkeep_does_not_restart_the_stream_once_it_is_armed() {
.all(|command| command.data[..4] != TX_VIDEO_START),
"no video start after the first frame"
);
assert_eq!(commands.len(), 1 + 3 * 4, "poll, two PWM holds, one clock");
assert_eq!(
commands.len(),
1 + 3 * 4 + 2 * 3 * 4,
"poll, PWM, RGB, clock"
);
let again = protocol.keepalive_commands();
assert_eq!(again.len(), 1 + 3 * 4);
assert_eq!(again.len(), commands.len());
}

/// All PWM envelopes precede recovery; unrelated upkeep must wait until the
/// full RGB transfer (including every data packet and repeated header) is sent.
fn assert_upkeep_restores_frame(
protocol: &WirelessControllerProtocol,
expected: &[ProtocolCommand],
) {
let commands = protocol.keepalive_commands();
let pwm_end = 1 + protocol.clusters().len() * 4;
let rgb_end = pwm_end + expected.len();
assert_eq!(commands.len(), rgb_end + 4, "poll, PWM, RGB, clock only");
assert_eq!(commands[0].transfer_type, TransferType::Companion);
for pwm in commands[1..pwm_end].chunks_exact(4) {
assert_eq!(pwm[0].data[5], RfSubCommand::Pwm as u8);
}
for (actual, expected) in commands[pwm_end..rgb_end].iter().zip(expected) {
assert_eq!(
actual.data, expected.data,
"restore the complete latest frame"
);
assert_eq!(actual.post_delay, expected.post_delay);
assert_eq!(actual.transfer_type, expected.transfer_type);
assert_eq!(actual.expects_response, expected.expects_response);
}
assert_eq!(commands[rgb_end].data[5], RfSubCommand::ClockSync as u8);
}

#[test]
fn upkeep_restores_the_latest_frame_before_clock_and_retains_it_while_idle() {
let protocol = discovered_protocol();
let _ = protocol.encode_frame(&[[255, 0, 0]; 5 * 26]);
let colors: Vec<_> = (0_u8..130)
.map(|index| [index, index.wrapping_mul(13), 255 - index])
.collect();
let latest = protocol.encode_frame(&colors);
assert_upkeep_restores_frame(&protocol, &latest);
// Idle delivery has no new encode calls, but PWM ticks still need recovery.
assert_upkeep_restores_frame(&protocol, &latest);
assert_eq!(protocol.capabilities().max_fps, 30);
assert_eq!(protocol.frame_interval(), Duration::from_millis(33));
}

#[test]
fn upkeep_restores_pause_black_and_empty_frames_without_resurrecting_old_colors() {
let protocol = discovered_protocol();
let _ = protocol.encode_frame(&[[255, 0, 0]; 5 * 26]);
let black = protocol.encode_frame(&[[0, 0, 0]; 5 * 26]);
assert_upkeep_restores_frame(&protocol, &black);

let _ = protocol.encode_frame(&[[0, 255, 0]; 5 * 26]);
let empty = protocol.encode_frame(&[]);
assert_eq!(
empty
.iter()
.map(|command| &command.data)
.collect::<Vec<_>>(),
black
.iter()
.map(|command| &command.data)
.collect::<Vec<_>>(),
"an empty frame pads all known fans with black"
);
assert_upkeep_restores_frame(&protocol, &empty);
}

#[test]
fn upkeep_preserves_right_attached_cluster_order_and_normalizes_input_lengths() {
let protocol = WirelessControllerProtocol::new();
protocol
.parse_response(&captured_master_reply())
.expect("master");
protocol
.parse_response(&table_with(&[
record([0x11; 6], 0, 12, 27),
record([0x22; 6], 0, 1, 27),
]))
.expect("one right-attached cluster and one normal cluster");
let _ = protocol.keepalive_commands();
let colors: Vec<_> = (0_u8..100).map(|index| [index, 0, 0]).collect();
let exact = protocol.encode_frame(&colors[..78]);
let excess = protocol.encode_frame(&colors);
assert_eq!(
exact
.iter()
.map(|command| &command.data)
.collect::<Vec<_>>(),
excess
.iter()
.map(|command| &command.data)
.collect::<Vec<_>>(),
"pixels outside the physical topology are ignored"
);
assert_upkeep_restores_frame(&protocol, &excess);
let short = protocol.encode_frame(&colors[..10]);
assert_upkeep_restores_frame(&protocol, &short);
}

#[test]
fn a_new_session_discards_cached_colors_before_rediscovery() {
let protocol = discovered_protocol();
let _ = protocol.encode_frame(&[[255, 0, 0]; 5 * 26]);
let _ = protocol.init_sequence();
protocol
.parse_response(&captured_master_reply())
.expect("controller rediscovered");
protocol
.parse_response(&table_with(&[record([0x33; 6], 0, 1, 27)]))
.expect("new cluster discovered");
let commands = protocol.keepalive_commands();
assert_eq!(
commands.len(),
1 + 2 + 4 + 4,
"no cached RGB in a new session"
);
assert_eq!(&commands[1].data[..4], &TX_VIDEO_START);
assert_eq!(commands[3].data[5], RfSubCommand::Pwm as u8);
assert_eq!(commands[7].data[5], RfSubCommand::ClockSync as u8);

let fresh = protocol.encode_frame(&[[1, 2, 3]; 26]);
assert_upkeep_restores_frame(&protocol, &fresh);
}

#[test]
Expand Down
12 changes: 12 additions & 0 deletions docs/specs/80-lian-li-tl-lcd-wireless-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,18 @@ occasionally spikes RPM.
and snapped to the newest frame each tick. The reference arms video
mode once as well.

PWM writes can interrupt direct RGB output and briefly expose the receiver's
onboard rainbow effect. After writing the PWM batch, upkeep immediately
re-sends each cluster's latest RGB frame through the normal transfer codec,
before clock or pairing work. Recovery starts only after a frame has
been encoded, preserves black frames, and never re-arms streaming mode.
The cached frame belongs to the connection session and is cleared on init.
The upstream driver documents the same interruption in
[issue 83](https://github.com/sgtaziz/lian-li-linux/issues/83) and restores
cached direct colors in [PR 149](https://github.com/sgtaziz/lian-li-linux/pull/149).
Packet-level tests establish recovery ordering and payload fidelity;
physical flicker removal still requires observation on the affected fans.

This holds user-set speeds steady without making Hypercolor a fan-curve
product. Exact clock-blob field values are validated on hardware before the
descriptor ships enabled (§11). Both upkeep streams and the GetDev poll run
Expand Down