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
32 changes: 32 additions & 0 deletions docs/hardware_platform_support.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,38 @@ Current profiles in `io_config.json`:
- `FOX_PI` (Luckfox Pico Pi)
- `LC_LAFRITE` (Libre Computer La Frite AML-S805X-AC, USB camera)

## Supported displays

SeedSigner supports several SPI display modules. The active display driver is
selected via **Settings → Hardware → Display type** (or via a SettingsQR).

| Display | Config value | Resolution | Driver | Notes |
|---------|-------------|------------|--------|-------|
| Waveshare 1.3" LCD HAT (ST7789) | `st7789_240x240` | 240×240 | `ST7789.py` | Default; original SeedSigner display |
| ST7789 320×240 (e.g. 2.0" IPS) | `st7789_320x240` | 320×240 | `st7789_mpy.py` | Natively portrait; 90° rotation applied |
| Waveshare 1.44" LCD HAT (ST7735S) | `st7735_128x128` | 128×128 | `ST7735.py` | UI renders at 240×240 and downscales |
| ILI9341 320×240 | `ili9341_320x240` | 320×240 | `ili9341.py` | Beta support |

The Waveshare 1.3" and 1.44" LCD HATs share the same GPIO40 header pinout and
use the same `RPI_40` hardware profile — only the display driver setting differs.
See `docs/io_config.md` for wiring details.

## Display switching shortcut (very-long-press)

While on the **home screen**, holding the joystick in one direction for
**5 seconds or more** will switch the display driver without navigating to
Settings. This is useful when the current display setting doesn't match the
physical hardware (e.g. after swapping HATs) and the screen is unreadable.

| Joystick direction | Switches to |
|--------------------|-------------|
| **Up** (hold 5 s) | ST7789 240×240 |
| **Right** (hold 5 s) | ST7789 320×240 |
| **Down** (hold 5 s) | ST7735 128×128 |

The setting is persisted if **Persistent Settings** is enabled. After switching,
the home screen re-renders automatically with the new display dimensions.

## Button GPIO mapping format

Button mappings live under each model's `buttons` object.
Expand Down
14 changes: 12 additions & 2 deletions docs/io_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ The table below uses standard 40-pin physical numbering and highlights:

## Waveshare SPI display pin notes

For the Waveshare 1.3" LCD HAT on a GPIO40 header:
The Waveshare 1.3" LCD HAT (ST7789, 240×240) and 1.44" LCD HAT (ST7735S,
128×128) share the same GPIO40 header pinout:
- `SPI0_MOSI`: pin `19` (`GPIO10`)
- `SPI0_SCLK`: pin `23` (`GPIO11`)
- `CS` / `LCD-CS` (`SPI0_CE0`): pin `24` (`GPIO8`)
Expand All @@ -50,7 +51,16 @@ For the Waveshare 1.3" LCD HAT on a GPIO40 header:
- Power: pin `1` (`3V3`)
- Ground: e.g. pin `6` (`GND`)

These are the standard Waveshare/RPi-style assignments that the `RPI_40` profile follows.
Both HATs use the same `RPI_40` hardware profile — the only difference is the
display driver setting:

| HAT | Display setting | Driver |
|-----|----------------|--------|
| 1.3" LCD HAT (240×240) | `st7789_240x240` (default) | `ST7789.py` |
| 1.44" LCD HAT (128×128) | `st7735_128x128` | `ST7735.py` |

To use the 1.44" HAT, change the **Display type** setting to `st7735 128x128`
(or use a SettingsQR with `disp_conf=st7735_128x128`).

### CS and the three wiring options

Expand Down
6 changes: 5 additions & 1 deletion src/seedsigner/gui/keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ def __init__(self,
selected_char="a",
rows=4,
cols=10,
rect=(0,40, 240,240),
rect=None,
additional_keys=[KEY_BACKSPACE],
auto_wrap=[WRAP_TOP, WRAP_BOTTOM, WRAP_LEFT, WRAP_RIGHT],
render_now=True,
Expand All @@ -192,6 +192,10 @@ def __init__(self,
self.charset = charset
self.rows = rows
self.cols = cols
if rect is None:
from seedsigner.gui.renderer import Renderer
renderer = Renderer.get_instance()
rect = (0, GUIConstants.TOP_NAV_HEIGHT, renderer.canvas_width, renderer.canvas_height)
self.rect = rect
self.font = Fonts.get_font(font_name, font_size)

Expand Down
30 changes: 27 additions & 3 deletions src/seedsigner/gui/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
DISPLAY_TYPE__ILI9341,
DISPLAY_TYPE__ILI9486,
DISPLAY_TYPE__ST7789,
DISPLAY_TYPE__ST7735,
DISPLAY_TYPE__DESKTOP,
DisplayDriver,
)
Expand All @@ -23,6 +24,8 @@ class Renderer(ConfigurableSingleton):
draw: ImageDraw.ImageDraw = None
disp = None
lock = Lock()
_needs_resize = False
_display_size = (0, 0)


@classmethod
Expand Down Expand Up @@ -60,21 +63,42 @@ def initialize_display(self):
self.canvas_width = self.disp.width
self.canvas_height = self.disp.height

elif self.display_type == DISPLAY_TYPE__ST7735:
# The UI is designed for 240×240; render at that resolution
# and downscale to the physical 128×128 display in
# show_image().
self.canvas_width = 240
self.canvas_height = 240

elif self.display_type in [DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486]:
# Swap for the natively portrait-oriented displays
self.canvas_width = self.disp.height
self.canvas_height = self.disp.width

self._needs_resize = (
self.canvas_width != self.disp.width
or self.canvas_height != self.disp.height
)
self._display_size = (self.disp.width, self.disp.height)

self.canvas = Image.new('RGB', (self.canvas_width, self.canvas_height))
self.draw = ImageDraw.Draw(self.canvas)
finally:
self.lock.release()


def _resize_for_display(self, image):
"""Downscale *image* to the physical display size when the canvas is
larger than the display (e.g. 240×240 canvas on a 128×128 ST7735)."""
if self._needs_resize:
return image.resize(self._display_size, Image.LANCZOS)
return image


def show_image(self, image=None, alpha_overlay=None, show_direct=False):
if show_direct:
# Use the incoming image as the canvas and immediately render
self.disp.show_image(image, 0, 0)
self.disp.show_image(self._resize_for_display(image), 0, 0)
return

if alpha_overlay:
Expand All @@ -86,7 +110,7 @@ def show_image(self, image=None, alpha_overlay=None, show_direct=False):
# Always write to the current canvas, rather than trying to replace it
self.canvas.paste(image)

self.disp.show_image(self.canvas, 0, 0)
self.disp.show_image(self._resize_for_display(self.canvas), 0, 0)


def show_image_pan(self, image, start_x, start_y, end_x, end_y, rate, alpha_overlay=None):
Expand Down Expand Up @@ -120,7 +144,7 @@ def show_image_pan(self, image, start_x, start_y, end_x, end_y, rate, alpha_over
# Always keep a copy of the current display in the canvas
self.canvas.paste(crop)

self.disp.show_image(crop, 0, 0)
self.disp.show_image(self._resize_for_display(crop), 0, 0)



Expand Down
56 changes: 54 additions & 2 deletions src/seedsigner/gui/screens/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
# screens with buttons.
RET_CODE__BACK_BUTTON = 1000
RET_CODE__POWER_BUTTON = 1001
RET_CODE__DISPLAY_TOGGLE = 1002



Expand Down Expand Up @@ -866,7 +867,7 @@ def run(self):
# Display the brightness tips toast
duration = 10 ** 9 * 1.2 # 1.2 seconds
if is_brightness_tip_enabled and time.time_ns() - self.tips_start_time.cur_count < duration:
image = self.qr_encoder.part_to_image(self.qr_encoder.cur_part(), 240, 240, border=2, background_color=hex_color)
image = self.qr_encoder.part_to_image(self.qr_encoder.cur_part(), self.renderer.canvas_width, self.renderer.canvas_height, border=2, background_color=hex_color)
Comment on lines 869 to +870
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QR images are now generated using (canvas_width, canvas_height). On non-square canvases (e.g. ILI9341 where canvas is 240x320), this will stretch the QR code via qrencode/PIL resize, which can reduce scan reliability. Consider generating a square QR image using a single size (e.g. min(canvas_width, canvas_height)) and centering/padding it instead of resizing to a rectangle.

Copilot uses AI. Check for mistakes.
self.render_brightness_tip(image)
pending_encoder_restart = True
else:
Expand All @@ -876,7 +877,7 @@ def run(self):
# brightness tip is stowed.
self.qr_encoder.restart()
pending_encoder_restart = False
image = self.qr_encoder.next_part_image(240, 240, border=2, background_color=hex_color)
image = self.qr_encoder.next_part_image(self.renderer.canvas_width, self.renderer.canvas_height, border=2, background_color=hex_color)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: next_part_image() is now called with (canvas_width, canvas_height), which will produce a non-square, stretched QR on portrait canvases. Use a square size (e.g. min dimension) and then composite onto the canvas to preserve module aspect ratio.

Suggested change
image = self.qr_encoder.next_part_image(self.renderer.canvas_width, self.renderer.canvas_height, border=2, background_color=hex_color)
canvas_width = self.renderer.canvas_width
canvas_height = self.renderer.canvas_height
qr_side = min(canvas_width, canvas_height)
qr_image = self.qr_encoder.next_part_image(
qr_side,
qr_side,
border=2,
background_color=hex_color,
)
# Center the square QR image on the full canvas to preserve aspect ratio
if qr_side == canvas_width and qr_side == canvas_height:
image = qr_image
else:
# Use the same background color as the QR image
background = "#" + hex_color
image = Image.new(qr_image.mode, (canvas_width, canvas_height), background)
offset_x = (canvas_width - qr_side) // 2
offset_y = (canvas_height - qr_side) // 2
image.paste(qr_image, (offset_x, offset_y))

Copilot uses AI. Check for mistakes.

with self.renderer.lock:
self.renderer.show_image(image)
Expand Down Expand Up @@ -1368,6 +1369,10 @@ class MainMenuScreen(LargeButtonScreen):
show_back_button: bool = False
show_power_button: bool = True

# Very-long-press (5 seconds) on a joystick direction switches the display driver.
VERY_LONG_PRESS_MS = 5000
_DIRECTION_TO_DISPLAY = None # built lazily in _run_callback
Comment on lines +1372 to +1374
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title/description focus on the phantom camera button press fix, but this PR also adds a home-screen very-long-press shortcut for switching display drivers (and related display driver/settings/docs changes). Please update the PR description/title to reflect this added scope, or split into separate PRs to keep review risk isolated.

Copilot uses AI. Check for mistakes.

def __post_init__(self):
super().__post_init__()
from seedsigner.hardware.battery_hat import BatteryHat
Expand All @@ -1380,7 +1385,54 @@ def __post_init__(self):
self.components.append(self.battery_indicator)
self.threads.append(MainMenuScreen.UpdateThread(self))

# State for tracking the very-long-press
self._hold_key = None
self._hold_start_ms = None

def _run_callback(self):
from seedsigner.models.settings import Settings

# Lazy-build the direction→display map (avoids import-time reference to
# SettingsConstants values that are plain strings).
if MainMenuScreen._DIRECTION_TO_DISPLAY is None:
MainMenuScreen._DIRECTION_TO_DISPLAY = {
HardwareButtonsConstants.KEY_UP: SettingsConstants.DISPLAY_CONFIGURATION__ST7789__240x240,
HardwareButtonsConstants.KEY_RIGHT: SettingsConstants.DISPLAY_CONFIGURATION__ST7789__320x240,
HardwareButtonsConstants.KEY_DOWN: SettingsConstants.DISPLAY_CONFIGURATION__ST7735__128x128,
}

cur_input = self.hw_inputs.cur_input
cur_time = int(time.time() * 1000)

if cur_input in self._DIRECTION_TO_DISPLAY:
if cur_input != self._hold_key:
# New direction; start tracking
self._hold_key = cur_input
self._hold_start_ms = cur_time
elif cur_time - self._hold_start_ms >= self.VERY_LONG_PRESS_MS:
new_config = self._DIRECTION_TO_DISPLAY[cur_input]
settings = Settings.get_instance()
current_config = settings.get_value(
SettingsConstants.SETTING__DISPLAY_CONFIGURATION
)
if new_config != current_config:
logger.info(
"Very-long-press detected (%s): switching display %s → %s",
cur_input, current_config, new_config,
)
settings.set_value(
SettingsConstants.SETTING__DISPLAY_CONFIGURATION,
new_config,
)
self.renderer.initialize_display()
return RET_CODE__DISPLAY_TOGGLE
# Same config already active; reset so we don't keep triggering
self._hold_key = None
self._hold_start_ms = None
else:
self._hold_key = None
self._hold_start_ms = None

return None

class UpdateThread(BaseThread):
Expand Down
17 changes: 16 additions & 1 deletion src/seedsigner/hardware/buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,14 +344,17 @@ def wait_for(self, keys: List = []) -> int:
self.cur_input = key
self.cur_input_started = cur_time
self.last_input_time = cur_time
self._low_since_ms[key] = None
return key
else:
if cur_time - self.last_input_time > self.next_repeat_threshold:
self.cur_input_started = cur_time
self.last_input_time = cur_time
self._low_since_ms[key] = None
return key
elif cur_time - self.cur_input_started > self.first_repeat_threshold:
self.last_input_time = cur_time
self._low_since_ms[key] = None
return key
else:
self._low_since_ms[key] = None
Expand Down Expand Up @@ -411,9 +414,21 @@ def check_for_low(self, key=None, keys: List = None) -> bool:
continue
if cur_time - low_since < self.debounce_threshold_ms:
continue
self._low_since_ms[key] = None
self.update_last_input_time()
return True
self._low_since_ms[key] = None
else:
# Pin is high. If it was previously observed as low, the
# button was pressed and released between polling calls.
# Treat this as a valid press provided the debounce
# interval was met, so that brief clicks are not lost in
# slow polling loops (e.g. camera preview).
low_since = self._low_since_ms.get(key)
if low_since is not None:
self._low_since_ms[key] = None
if cur_time - low_since >= self.debounce_threshold_ms:
self.update_last_input_time()
return True
return False

pygame.event.pump()
Expand Down
Loading
Loading