feat(eckhart): connected indicator in DeviceMenu
What changed, and why it matters
This commit is a user-interface feature for the Trezor hardware wallet. It adds a small visual indicator (a connection icon) next to the 'Connected' / 'Disconnected' status in the device menu and makes the menu refresh when USB or Bluetooth connection state changes. There is no evidence in the diff of a security vulnerability, exploit, or bug fix.
No security action required. Review as normal UI feature code.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces a ConnectionIndicator UI component in the Rust layout layer and wires it into the Button rendering for menu items that show a connection subtext. It also refactors Python code so the device menu uses a new UsbAwareLayout base class that listens for io.USB_EVENT and forwards USB connect/disconnect events to the Rust layout, allowing the menu to update the indicator in real time. The previous logic derived ‘connected’ status from a Bluetooth paired-devices index; the new logic uses usb::usb_configured() plus BLE connection state. This is a feature/refactoring commit with no security-relevant code changes visible in the diff.
Changed components
core/embed/rust/src/ui/layout_eckhart/component/button.rscore/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rscore/src/apps/homescreen/device_menu.pycore/src/trezor/ui/layouts/homescreen.pyInspect captured patch +120 / −46
diff --git a/core/embed/rust/src/ui/layout_eckhart/component/button.rs b/core/embed/rust/src/ui/layout_eckhart/component/button.rs
index a5aa7d89..e8167777 100644
--- a/core/embed/rust/src/ui/layout_eckhart/component/button.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/component/button.rs
@@ -17,7 +17,10 @@ use crate::{
},
};
-use super::super::theme::{self, Gradient};
+use super::super::{
+ component::ConnectionIndicator,
+ theme::{self, Gradient},
+};
pub enum ButtonMsg {
Pressed,
@@ -56,6 +59,7 @@ pub struct Button {
long_timer: Timer,
haptic: HapticMode,
subtext_marquee: Option<Marquee>,
+ connection_indicator: Option<ConnectionIndicator>,
#[cfg(feature = "ui_debug")]
skip_test_visit: bool, // used by debuglink
}
@@ -65,6 +69,7 @@ impl Button {
const MENU_ITEM_ALIGNMENT: Alignment = Alignment::Start;
pub const MENU_ITEM_CONTENT_OFFSET: Offset = Offset::x(12);
const CONN_ICON_WIDTH: i16 = 34;
+ const SUBTEXT_GAP: i16 = 4; // extra pixels between text and subtext
#[cfg(feature = "micropython")]
const DEFAULT_STYLESHEET: ButtonStyleSheet = theme::firmware::button_default();
@@ -85,6 +90,13 @@ impl Button {
)),
_ => None,
};
+ let connection_indicator = match content {
+ ButtonContent::TextAndSubtext {
+ connection_indicator: true,
+ ..
+ } => Some(ConnectionIndicator::new()),
+ _ => None,
+ };
Self {
content,
content_offset: Offset::zero(),
@@ -99,6 +111,7 @@ impl Button {
long_timer: Timer::new(),
haptic: HapticMode::OnPress,
subtext_marquee,
+ connection_indicator,
#[cfg(feature = "ui_debug")]
skip_test_visit: false,
}
@@ -162,23 +175,22 @@ impl Button {
stylesheet: ButtonStyleSheet,
connected: bool,
) -> Self {
- let (subtext, subtext_style) = if connected {
- (
- TR::words__connected.into(),
- &theme::TEXT_MENU_ITEM_SUBTITLE_GREEN,
- )
+ let subtext = if connected {
+ TR::words__connected.into()
} else {
- (
- TR::words__disconnected.into(),
- &theme::TEXT_MENU_ITEM_SUBTITLE,
- )
+ TR::words__disconnected.into()
};
- Self::with_clipped_text_and_subtext(text, subtext, subtext_style)
- .with_text_align(Self::MENU_ITEM_ALIGNMENT)
- .with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET)
- .styled(stylesheet)
- .with_radius(Self::MENU_ITEM_RADIUS)
+ Self::with_clipped_text_and_subtext(
+ text,
+ subtext,
+ &theme::TEXT_MENU_ITEM_SUBTITLE,
+ connected,
+ )
+ .with_text_align(Self::MENU_ITEM_ALIGNMENT)
+ .with_content_offset(Self::MENU_ITEM_CONTENT_OFFSET)
+ .styled(stylesheet)
+ .with_radius(Self::MENU_ITEM_RADIUS)
}
pub const fn with_single_line_text(text: TString<'static>) -> Self {
@@ -205,12 +217,18 @@ impl Button {
text: TString<'static>,
subtext: TString<'static>,
subtext_style: &'static TextStyle,
+ connected_indicator: bool,
) -> Self {
- Self::new(ButtonContent::clipped_text_and_subtext(
+ let mut button = Self::new(ButtonContent::clipped_text_and_subtext(
text,
subtext,
subtext_style,
- ))
+ connected_indicator,
+ ));
+ if connected_indicator {
+ button.connection_indicator = Some(ConnectionIndicator::new_polled());
+ }
+ button
}
pub fn with_single_line_text_and_subtext(
@@ -419,6 +437,7 @@ impl Button {
..
} => text.map(|t| {
self.text_height(t, *single_line, *break_words, width)
+ + Self::SUBTEXT_GAP
+ self.baseline_subtext_height()
}),
#[cfg(feature = "micropython")]
@@ -640,6 +659,9 @@ impl Button {
}
});
+ if let Some(ci) = &self.connection_indicator {
+ ci.render(target);
+ }
if let Some(m) = &self.subtext_marquee {
m.render(target);
} else {
@@ -693,14 +715,41 @@ impl Component for Button {
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
- if let ButtonContent::TextAndSubtext { .. } = self.content {
+ if let ButtonContent::TextAndSubtext {
+ connection_indicator: connected_indicator,
+ ..
+ } = self.content
+ {
let subtext_start = (bounds.height() + self.content_height(bounds.width())) / 2
- self.baseline_subtext_height();
+
+ // Place the connection indicator if present
+ if connected_indicator {
+ if let Some(ci) = self.connection_indicator.as_mut() {
+ let ci_size = ConnectionIndicator::AREA_SIZE_NEEDED;
+ let ci_area = Rect::from_top_left_and_size(
+ Point::new(
+ bounds.top_left().x + self.content_offset.x,
+ bounds.top_left().y + subtext_start,
+ ),
+ Offset::new(ci_size, ci_size),
+ );
+ ci.place(ci_area);
+ }
+ }
+
if let Some(m) = self.subtext_marquee.as_mut() {
+ const INDICATOR_SUBTEXT_GAP: i16 = 12;
+ let indicator_offset = if let Some(ci) = &self.connection_indicator {
+ ci.content_width() + INDICATOR_SUBTEXT_GAP
+ } else {
+ 0
+ };
let marquee_area = self
.area
.inset(Insets::top(subtext_start))
- .inset(Insets::sides(self.content_offset.x));
+ .inset(Insets::sides(self.content_offset.x))
+ .inset(Insets::left(indicator_offset));
m.place(marquee_area);
}
}
@@ -712,6 +761,7 @@ impl Component for Button {
if let Some(m) = &mut self.subtext_marquee {
m.event(ctx, event);
}
+
let touch_area = self.touch_area();
match event {
Event::Touch(TouchEvent::TouchStart(pos)) => {
@@ -866,6 +916,7 @@ pub enum ButtonContent {
break_words: bool,
subtext: TString<'static>,
subtext_style: &'static TextStyle,
+ connection_indicator: bool,
},
Icon(Icon),
#[cfg(feature = "micropython")]
@@ -898,6 +949,7 @@ impl ButtonContent {
break_words: false,
subtext,
subtext_style,
+ connection_indicator: false,
}
}
@@ -905,6 +957,7 @@ impl ButtonContent {
text: TString<'static>,
subtext: TString<'static>,
subtext_style: &'static TextStyle,
+ connected_indicator: bool,
) -> Self {
Self::TextAndSubtext {
text,
@@ -912,6 +965,7 @@ impl ButtonContent {
break_words: true,
subtext,
subtext_style,
+ connection_indicator: connected_indicator,
}
}
@@ -926,6 +980,7 @@ impl ButtonContent {
break_words: false,
subtext,
subtext_style,
+ connection_indicator: false,
}
}
}
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs
index 7cfb1ad8..47f21949 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs
@@ -6,6 +6,7 @@ use crate::{
micropython::{gc::GcBox, obj::Obj},
strutil::TString,
translations::TR,
+ trezorhal::usb,
ui::{
component::{
text::{
@@ -22,7 +23,9 @@ use crate::{
};
#[cfg(feature = "ble")]
-use crate::ui::event::BLEEvent;
+use crate::{trezorhal::ble, ui::event::BLEEvent};
+
+use crate::ui::event::USBEvent;
use super::{
super::{
@@ -325,7 +328,10 @@ impl DeviceMenuScreen {
screen.register_settings_menu(ble_enabled);
screen.register_power_menu();
- let is_connected = connected_idx.is_some_and(|idx| usize::from(idx) < paired_devices.len());
+ let is_connected = usb::usb_configured();
+ #[cfg(feature = "ble")]
+ let is_connected = is_connected || ble::is_connected();
+
let connected_subtext: Option<TString<'static>> =
is_connected.then_some(TR::words__connected.into());
@@ -716,11 +722,10 @@ impl DeviceMenuScreen {
}
if self.has_submenu(DeviceMenuId::PairAndConnect) {
+ let connected = connected_subtext.is_some();
let it =
MenuItem::go_to_submenu(TR::ble__pair_title.into(), DeviceMenuId::PairAndConnect)
- .with_subtext(
- connected_subtext.map(|t| (t, Some(&theme::TEXT_MENU_ITEM_SUBTITLE_GREEN))),
- );
+ .with_connection_status(Some(connected));
items.add(it);
}
@@ -1001,11 +1006,17 @@ impl Component for DeviceMenuScreen {
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
- #[cfg(feature = "ble")]
- if matches!(
- event,
- Event::BLE(BLEEvent::Connected | BLEEvent::Disconnected | BLEEvent::ConnectionChanged)
- ) {
+ let refresh = match event {
+ Event::USB(USBEvent::Configured | USBEvent::Deconfigured) => true,
+
+ #[cfg(feature = "ble")]
+ Event::BLE(
+ BLEEvent::Connected | BLEEvent::Disconnected | BLEEvent::ConnectionChanged,
+ ) => true,
+
+ _ => false,
+ };
+ if refresh {
let submenu_idx = match self.active_screen.deref_mut() {
ActiveScreen::Menu(_, id) => *id,
ActiveScreen::Device(_) => DeviceMenuId::PairAndConnect,
diff --git a/core/src/apps/homescreen/device_menu.py b/core/src/apps/homescreen/device_menu.py
index 863531e0..d950d9c3 100644
--- a/core/src/apps/homescreen/device_menu.py
+++ b/core/src/apps/homescreen/device_menu.py
@@ -4,8 +4,9 @@ from typing import TYPE_CHECKING
import storage.device as storage_device
import trezorble as ble
import trezorui_api
-from trezor import TR, config, log, utils
+from trezor import TR, config, log, utils, workflow
from trezor.ui.layouts import interact, raise_if_not_confirmed
+from trezor.ui.layouts.homescreen import UsbAwareLayout
from trezor.wire import ActionCancelled, PinCancelled
from trezorui_api import CANCELLED, DeviceMenuResult
@@ -116,7 +117,8 @@ async def handle_device_menu() -> None:
firmware_type = "Bitcoin-only" if utils.BITCOIN_ONLY else "Universal"
production_year = _get_production_year()
- menu_result = await interact(
+ workflow.close_others()
+ obj = UsbAwareLayout(
trezorui_api.show_device_menu(
init_submenu_idx=init_submenu_idx,
backup_failed=backup_failed,
@@ -153,9 +155,11 @@ async def handle_device_menu() -> None:
],
production_year=production_year,
),
- "device_menu",
- raise_on_cancel=None,
)
+ try:
+ menu_result = await obj.get_result()
+ finally:
+ obj.__del__()
if menu_result is CANCELLED:
return
diff --git a/core/src/trezor/ui/layouts/homescreen.py b/core/src/trezor/ui/layouts/homescreen.py
index ceed646e..b54efb4d 100644
--- a/core/src/trezor/ui/layouts/homescreen.py
+++ b/core/src/trezor/ui/layouts/homescreen.py
@@ -38,7 +38,23 @@ def _retry_with_gc(layout: Callable[P, R], *args: P.args, **kwargs: P.kwargs) ->
return layout(*args, **kwargs)
-class HomescreenBase(ui.Layout):
+class UsbAwareLayout(ui.Layout):
+ """Layout that listens for USB connect/disconnect events."""
+
+ async def usb_checker_task(self) -> None:
+ from trezor import io, loop
+
+ usbcheck = loop.wait(io.USB_EVENT)
+ while True:
+ event = await usbcheck
+ self._event(self.layout.usb_event, event)
+
+ def create_tasks(self) -> Iterator[loop.Task]:
+ yield from super().create_tasks()
+ yield self.usb_checker_task()
+
+
+class HomescreenBase(UsbAwareLayout):
RENDER_INDICATOR: object | None = None
def __init__(self, layout: Any) -> None:
@@ -78,18 +94,6 @@ class Homescreen(HomescreenBase):
)
)
- async def usb_checker_task(self) -> None:
- from trezor import io, loop
-
- usbcheck = loop.wait(io.USB_EVENT)
- while True:
- event = await usbcheck
- self._event(self.layout.usb_event, event)
-
- def create_tasks(self) -> Iterator[loop.Task]:
- yield from super().create_tasks()
- yield self.usb_checker_task()
-
class Lockscreen(HomescreenBase):
RENDER_INDICATOR = storage_cache.LOCKSCREEN_ON
Why this scored 12/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.