feat(core): use LED effect for BLE pairing
What changed, and why it matters
This commit is a straightforward user-interface feature: it makes the Trezor T3W1 device show a special LED lighting effect while Bluetooth Low Energy (BLE) pairing is in progress. It refactors how the firmware tracks LED state so that the renderer can request either a static color or a hardware-driven effect (pairing/charging). There is no security-relevant change visible in the diff.
No security action required; treat as normal feature review. If desired, verify that the C implementation of `rgb_led_effect_start`/`rgb_led_effect_get_type` does not introduce concurrency or resource issues, but that is outside the scope of this Rust-only commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces an LedState enum (Static(Color) vs Effect(Effect)) and an Effect enum (Pairing, Charging) backed by C FFI helpers rgb_led_effect_start/rgb_led_effect_get_type. It replaces the renderer’s single led_color: Option<Color> with led_state: Option<LedState> and updates all call sites (homescreen, hold-to-confirm, error screen, tutorial, BLE component, swipe flow, layout object) to use set_led_state. The BLE pairing component now requests LedState::Effect(Effect::Pairing) during render. The implementation avoids restarting an already-running effect and stops any ongoing effect when a static color is set via set_color.
Changed components
core/embed/rust/src/ui/led.rs (new)core/embed/rust/src/trezorhal/rgb_led.rscore/embed/rust/src/ui/component/ble.rscore/embed/rust/src/ui/shape/render.rscore/embed/rust/src/ui/flow/swipe.rscore/embed/rust/src/ui/layout/obj.rscore/embed/rust/src/ui/layout_eckhart/component/error.rscore/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rscore/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rscore/embed/rust/src/ui/layout_eckhart/firmware/tutorial_screen.rsInspect captured patch +97 / −33
diff --git a/core/.changelog.d/6076.added b/core/.changelog.d/6076.added
new file mode 100644
index 00000000..29e9ae74
--- /dev/null
+++ b/core/.changelog.d/6076.added
@@ -0,0 +1 @@
+[T3W1] Use LED effect for BLE pairing.
diff --git a/core/embed/rust/src/trezorhal/rgb_led.rs b/core/embed/rust/src/trezorhal/rgb_led.rs
index 123ae4ce..369f7827 100644
--- a/core/embed/rust/src/trezorhal/rgb_led.rs
+++ b/core/embed/rust/src/trezorhal/rgb_led.rs
@@ -1,18 +1,38 @@
-#![allow(dead_code)]
-
-#[cfg(feature = "ui")]
-use crate::ui::display::Color;
-
use super::ffi;
pub fn set_color(color: u32) {
unsafe {
+ // also stops ongoing LED effect (if previously started)
ffi::rgb_led_set_color(color);
}
}
-/// Set LED color if provided, otherwise turn the LED off.
-#[cfg(feature = "ui")]
-pub fn set(color: Color) {
- set_color(color.to_u32());
+#[derive(PartialEq, Eq, Clone, Copy)]
+pub enum Effect {
+ Pairing,
+ Charging,
+}
+
+impl Effect {
+ pub fn set(&self) {
+ if Some(self) == Self::current().as_ref() {
+ // don't stop ongoing LED effect (if previously started).
+ return;
+ }
+ let effect_type = match self {
+ Effect::Pairing => ffi::rgb_led_effect_type_t_RGB_LED_EFFECT_PAIRING,
+ Effect::Charging => ffi::rgb_led_effect_type_t_RGB_LED_EFFECT_CHARGING,
+ };
+ unsafe { ffi::rgb_led_effect_start(effect_type, 0) }
+ }
+
+ /// return current effect (if previously started).
+ fn current() -> Option<Self> {
+ let effect_type = unsafe { ffi::rgb_led_effect_get_type() };
+ Some(match effect_type {
+ ffi::rgb_led_effect_type_t_RGB_LED_EFFECT_PAIRING => Effect::Pairing,
+ ffi::rgb_led_effect_type_t_RGB_LED_EFFECT_CHARGING => Effect::Charging,
+ _ => return None,
+ })
+ }
}
diff --git a/core/embed/rust/src/ui/component/ble.rs b/core/embed/rust/src/ui/component/ble.rs
index 7ba9ba37..814381d1 100644
--- a/core/embed/rust/src/ui/component/ble.rs
+++ b/core/embed/rust/src/ui/component/ble.rs
@@ -2,6 +2,7 @@ use crate::ui::{
component::{Component, Event, EventCtx},
event::BLEEvent,
geometry::Rect,
+ led::{Effect, LedState},
shape::Renderer,
};
@@ -65,7 +66,9 @@ where
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
- self.inner.render(target)
+ self.inner.render(target);
+ #[cfg(feature = "rgb_led")]
+ target.set_led_state(LedState::Effect(Effect::Pairing));
}
}
diff --git a/core/embed/rust/src/ui/flow/swipe.rs b/core/embed/rust/src/ui/flow/swipe.rs
index d4547074..314eecc8 100644
--- a/core/embed/rust/src/ui/flow/swipe.rs
+++ b/core/embed/rust/src/ui/flow/swipe.rs
@@ -1,5 +1,3 @@
-#[cfg(feature = "rgb_led")]
-use crate::trezorhal::rgb_led;
use crate::{
error::{self, Error},
maybe_trace::MaybeTrace,
@@ -317,7 +315,7 @@ impl Layout<Result<Obj, Error>> for SwipeFlow {
self.current_page().render(target);
#[cfg(feature = "rgb_led")]
- rgb_led::set(target.led_color());
+ target.led_state().set();
#[cfg(feature = "ui_debug")]
if target.should_raise_overflow_exception() {
diff --git a/core/embed/rust/src/ui/layout/obj.rs b/core/embed/rust/src/ui/layout/obj.rs
index 9fafa599..cfc3f779 100644
--- a/core/embed/rust/src/ui/layout/obj.rs
+++ b/core/embed/rust/src/ui/layout/obj.rs
@@ -14,8 +14,6 @@ use num_traits::ToPrimitive;
#[cfg(feature = "ble")]
use crate::ui::event::BLEEvent;
-#[cfg(feature = "rgb_led")]
-use crate::trezorhal::rgb_led;
#[cfg(feature = "button")]
use crate::{
trezorhal::button::{PhysicalButton, PhysicalButtonEvent},
@@ -161,7 +159,7 @@ where
self.inner.render(target);
#[cfg(feature = "rgb_led")]
- rgb_led::set(target.led_color());
+ target.led_state().set();
#[cfg(feature = "ui_debug")]
if target.should_raise_overflow_exception() {
diff --git a/core/embed/rust/src/ui/layout_eckhart/component/error.rs b/core/embed/rust/src/ui/layout_eckhart/component/error.rs
index 072ad4f8..7f44ae02 100644
--- a/core/embed/rust/src/ui/layout_eckhart/component/error.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/component/error.rs
@@ -1,3 +1,5 @@
+#[cfg(feature = "rgb_led")]
+use crate::ui::led::LedState;
use crate::{
strutil::TString,
ui::{
@@ -85,6 +87,6 @@ impl<'a> Component for ErrorScreen<'a> {
self.wait_for_restart.render(target);
self.screen_border.render(u8::MAX, target);
#[cfg(feature = "rgb_led")]
- target.set_led_color(LED_RED);
+ target.set_led_state(LedState::Static(LED_RED));
}
}
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs
index 2997a9ea..b1f4a2bb 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/hold_to_confirm.rs
@@ -21,6 +21,9 @@ use pareen;
#[cfg(feature = "haptic")]
use crate::trezorhal::haptic;
+#[cfg(feature = "rgb_led")]
+use crate::ui::led::LedState;
+
/// A component that displays a border that grows from the bottom of the screen
/// to the top. The animation is parametrizable by color and duration.
pub struct HoldToConfirmAnim {
@@ -213,7 +216,7 @@ impl Component for HoldToConfirmAnim {
}
}
#[cfg(feature = "rgb_led")]
- target.set_led_color(self.led_color);
+ target.set_led_state(LedState::Static(self.led_color));
}
}
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rs
index c0eac0a0..7505b25c 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/homescreen.rs
@@ -23,6 +23,9 @@ use super::{
ActionBar, ActionBarMsg, Hint,
};
+#[cfg(feature = "rgb_led")]
+use crate::ui::led::LedState;
+
/// Full-screen component for the homescreen and lockscreen.
pub struct Homescreen {
/// Device name with shadow
@@ -209,7 +212,9 @@ impl Component for Homescreen {
}
#[cfg(feature = "rgb_led")]
- target.set_led_color(self.led_color.unwrap_or_else(Color::black));
+ target.set_led_state(LedState::Static(
+ self.led_color.unwrap_or_else(Color::black),
+ ));
}
}
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/tutorial_screen.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/tutorial_screen.rs
index 10c0a5a6..16544d47 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/tutorial_screen.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/tutorial_screen.rs
@@ -21,6 +21,9 @@ use super::{
ActionBar, ActionBarMsg,
};
+#[cfg(feature = "rgb_led")]
+use crate::ui::led::LedState;
+
// Duration of the loader animation
const LOADER_DURATION: Duration = Duration::from_secs(3);
// Loader animation + gradient duration
@@ -108,7 +111,7 @@ impl Component for TutorialWelcomeScreen {
#[cfg(feature = "rgb_led")]
if !loader_running {
ScreenBackground::new(Some(LED_COLOR), None).render(target);
- target.set_led_color(LED_COLOR);
+ target.set_led_state(LedState::Static(LED_COLOR));
}
self.text.render(target);
diff --git a/core/embed/rust/src/ui/led.rs b/core/embed/rust/src/ui/led.rs
new file mode 100644
index 00000000..c9372eb9
--- /dev/null
+++ b/core/embed/rust/src/ui/led.rs
@@ -0,0 +1,26 @@
+pub use crate::trezorhal::rgb_led::Effect;
+
+use crate::trezorhal::rgb_led::set_color;
+
+use super::display::Color;
+
+#[derive(PartialEq, Eq, Clone, Copy)]
+pub enum LedState {
+ Static(Color),
+ Effect(Effect),
+}
+
+impl Default for LedState {
+ fn default() -> Self {
+ LedState::Static(Color::black())
+ }
+}
+
+impl LedState {
+ pub fn set(&self) {
+ match self {
+ Self::Static(color) => set_color(color.to_u32()),
+ Self::Effect(effect) => effect.set(),
+ }
+ }
+}
diff --git a/core/embed/rust/src/ui/mod.rs b/core/embed/rust/src/ui/mod.rs
index 48e1f955..060985a4 100644
--- a/core/embed/rust/src/ui/mod.rs
+++ b/core/embed/rust/src/ui/mod.rs
@@ -17,6 +17,9 @@ pub mod layout;
mod api;
+#[cfg(feature = "rgb_led")]
+mod led;
+
#[cfg(feature = "layout_bolt")]
pub mod layout_bolt;
#[cfg(feature = "layout_caesar")]
diff --git a/core/embed/rust/src/ui/shape/render.rs b/core/embed/rust/src/ui/shape/render.rs
index 47b65500..a978cbbc 100644
--- a/core/embed/rust/src/ui/shape/render.rs
+++ b/core/embed/rust/src/ui/shape/render.rs
@@ -1,3 +1,5 @@
+#[cfg(feature = "rgb_led")]
+use crate::ui::led::LedState;
use crate::ui::{
display::Color,
geometry::{Offset, Rect},
@@ -60,10 +62,10 @@ pub trait Renderer<'a> {
}
#[cfg(feature = "rgb_led")]
- fn set_led_color(&mut self, color: Color);
+ fn set_led_state(&mut self, led: LedState);
#[cfg(feature = "rgb_led")]
- fn led_color(&self) -> Color;
+ fn led_state(&self) -> LedState;
#[cfg(feature = "ui_debug")]
fn raise_overflow_exception(&mut self);
@@ -87,7 +89,7 @@ where
cache: &'a DrawingCache<'alloc>,
#[cfg(feature = "rgb_led")]
- led_color: Option<Color>,
+ led_state: Option<LedState>,
#[cfg(feature = "ui_debug")]
overflow: bool,
@@ -114,7 +116,7 @@ where
canvas,
cache,
#[cfg(feature = "rgb_led")]
- led_color: None,
+ led_state: None,
#[cfg(feature = "ui_debug")]
overflow: false,
}
@@ -144,15 +146,15 @@ where
}
#[cfg(feature = "rgb_led")]
- fn set_led_color(&mut self, color: Color) {
+ fn set_led_state(&mut self, led_state: LedState) {
// LED color should be set once per rendered frame.
- debug_assert!(self.led_color.is_none_or(|current| current == color));
- self.led_color = Some(color);
+ debug_assert!(self.led_state.is_none_or(|current| current == led_state));
+ self.led_state = Some(led_state);
}
#[cfg(feature = "rgb_led")]
- fn led_color(&self) -> Color {
- self.led_color.unwrap_or_else(Color::black)
+ fn led_state(&self) -> LedState {
+ self.led_state.unwrap_or_default()
}
#[cfg(feature = "ui_debug")]
@@ -213,13 +215,13 @@ where
}
#[cfg(feature = "rgb_led")]
- fn set_led_color(&mut self, color: Color) {
- self.renderer.set_led_color(color);
+ fn set_led_state(&mut self, led_state: LedState) {
+ self.renderer.set_led_state(led_state);
}
#[cfg(feature = "rgb_led")]
- fn led_color(&self) -> Color {
- self.renderer.led_color()
+ fn led_state(&self) -> LedState {
+ self.renderer.led_state()
}
#[cfg(feature = "ui_debug")]
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.