Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ src/seedsigner/models/settings_definition.json
.coverage*

*.po
*.mo
*.mo
*.bmp
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,4 +357,5 @@ Letter templates(8.5in * 11in):
See the [SeedSigner OS repo](https://github.com/SeedSigner/seedsigner-os/) for instructions.

# Developer Local Build Instructions
Raspberry Pi OS is commonly used for development. See the [Raspberry Pi OS Build Instructions](docs/raspberry_pi_os_build_instructions.md)
* Raspberry Pi OS is commonly used for development. See the [Raspberry Pi OS Build Instructions](docs/raspberry_pi_os_build_instructions.md)
* Desktop build for development and simulation. See the [Local SeedSigner Simulator Setup](docs/local_simulator_setup_instructions.md)
Binary file added docs/img/simulator.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 89 additions & 0 deletions docs/local_simulator_setup_instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Local SeedSigner Simulator Setup

This guide adds a method to setup SeedSigner testing locally through a simulator GUI for easy development. You can either interact with the buttons at the window or use keyboard arrows, Enter, Numpad_1, Numpad_2, Numpad_3

Note: The has only been tested on macOS (26) using Homebrew and Python 3.14.

<img src="img/simulator.png" width=320 />

---

### Prerequisites

- A desktop / laptop with webcam

---

### Installing system dependencies

SeedSigner’s simulator depends on Python with `tkinter` support, native libraries for QR scanning and camera access.
- `tkinter` is Python's built-in library for creating desktop GUI applications.
- `pyzbar` library requires a backend to be running on the system called `zbar`.

#### MacOS (Homebrew)
```bash
brew install python@3.14 # includes tkinter support
brew install zbar
```
#### Linux
```bash
sudo apt-get install python3-tk
sudo apt install libzbar0
```

### Setting up dev environment

```bash
git clone https://github.com/SeedSigner/seedsigner

python3 -m venv env
source env/bin/activate

# should not fail loading tkinter
python3 -m tkinter

pip install -r tools/emulator/requirements-simulator.txt
```

#### Camera Permissions (macOS Security)

On first run, macOS will block camera access. You may see logs like:

```
OpenCV: not authorized to capture video
OpenCV: camera failed to properly initialize
```

#### Grant Camera Permission (macOS)

Go to: System Settings → Privacy & Security → Camera

Enable camera access for:

- Terminal (if you run from Terminal)
- VS Code (if you run from VS Code)

Then restart Terminal and rerun the simulator.

You can also reset permissions if macOS didn’t prompt:

```bash
tccutil reset Camera
```

### Running the Simulator

```bash
python tools/emulator/run_emulator.py
```

### Notes

- The simulator environment is **not security-hardened** and should never be used with real funds.
- Camera access, QR decoding, and GUI rendering are all platform-dependent; macOS quirks differ from Raspberry Pi OS.
- For hardware deployment, follow the official Raspberry Pi OS instructions in the main project README.

### Credits

- The original work is inspired by https://github.com/enteropositivo/seedsigner-emulator, which was the first working emulator POC that directly replaced files in the SeedSigner source code for the camera, buttons, and renderer using Tkinter-based implementations.
- The modified work from https://github.com/ltcmweb/seedsigner based on @enteropositivo's work added macOS support with local emulation of the GPIO socket.
173 changes: 173 additions & 0 deletions tools/emulator/patches/camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Forked and modified from @enteropositivo
# https://github.com/enteropositivo/seedsigner-emulator/blob/master/seedsigner/hardware/camera.py
# https://github.com/enteropositivo/seedsigner-emulator/blob/master/seedsigner/emulator/webcamvideostream.py

from threading import Thread
import time
import cv2
import numpy as np
from PIL import Image

from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.singleton import Singleton


class MockCamera(Singleton):
_video_stream = None
_picamera = None
_camera_rotation = None

@classmethod
def get_instance(cls):
# This is the only way to access the one and only Controller
print("INSTANCE")
if cls._instance is None:
cls._instance = cls.__new__(cls)
cls._instance._camera_rotation = int(
Settings.get_instance().get_value(
SettingsConstants.SETTING__CAMERA_ROTATION
)
)
cls._instance._camera_rotation += 90
return cls._instance

def start_video_stream_mode(
self, resolution=(512, 384), framerate=12, format="bgr"
):
if self._video_stream is not None:
self.stop_video_stream_mode()

try:
self._video_stream = WebcamVideoStream(
resolution=resolution, framerate=framerate, format=format
)
self._video_stream.start()
except Exception as e:
raise e

def read_video_stream(self, as_image=False):
if not self._video_stream:
raise Exception("Must call start_video_stream first.")
frame = self._video_stream.read()
if not as_image:
return frame
else:
if frame is not None:
return Image.fromarray(frame.astype("uint8"), "RGB").rotate(
90 + self._camera_rotation
)
return None

def stop_video_stream_mode(self):
if self._video_stream is not None:
self._video_stream.stop()
self._video_stream = None

def start_single_frame_mode(self, resolution=(720, 480)):
if self._video_stream is not None:
self.stop_video_stream_mode()
if self._picamera is not None:
self._picamera.close()

def capture_frame(self):
print("CAPTURE")
frame = WebcamVideoStream.single_frame()
return Image.fromarray(frame).rotate(90 + self._camera_rotation)

def stop_single_frame_mode(self):
if self._picamera is not None:
self._picamera.close()
self._picamera = None


def resize_and_center_crop_240(frame_bgr: np.ndarray) -> np.ndarray:
target = 240
h, w = frame_bgr.shape[:2]

# resize to 240
if w < h:
scale = target / w
else:
scale = target / h

new_w = int(w * scale)
new_h = int(h * scale)

resized = cv2.resize(frame_bgr, (new_w, new_h), interpolation=cv2.INTER_AREA)

# center crop
x1 = (new_w - target) // 2
y1 = (new_h - target) // 2

cropped = resized[y1 : y1 + target, x1 : x1 + target]

return cropped


class WebcamVideoStream:
def __init__(self, resolution=(320, 240), framerate=32, format="bgr", **kwargs):
# initialize the camera
self.camera = cv2.VideoCapture(0)
self.set_resolution(resolution)

# initialize the frame and the variable used to indicate
# if the thread should be stopped
self.frame = None
self.should_stop = False
self.is_stopped = True

def start(self):
# start the thread to read frames from the video stream
t = Thread(target=self.update, args=())
t.daemon = True
t.start()
self.is_stopped = False
return self

def update(self):
if self.camera.isOpened():
# keep looping infinitely until the thread is stopped
while not self.should_stop:
# grab the frame from the stream and clear the stream in
# preparation for the next frame
ret, stream = self.camera.read()
stream = cv2.resize(stream, (240, 240))
stream = cv2.cvtColor(stream, cv2.COLOR_BGR2RGB)
time.sleep(0.05)
self.frame = stream
self.is_stopped = True
self.should_stop = False

def read(self):
return self.frame

def stop(self):
# indicate that the thread should be stopped
self.should_stop = True

# Block in this thread until stopped
while not self.is_stopped:
pass

def set_resolution(self, resolution):
self.camera.set(3, resolution[0])
self.camera.set(4, resolution[1])

@staticmethod
def single_frame():
cap = cv2.VideoCapture(0)

# Warm-up, let auto-exposure settle in
for _ in range(30):
cap.read()
time.sleep(0.01)

ret, frame = cap.read()
cap.release()

if not ret:
raise RuntimeError("Camera capture failed")

frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frame = resize_and_center_crop_240(frame)
return frame
52 changes: 52 additions & 0 deletions tools/emulator/patches/gpio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Forked and modifed from @ltcmweb
# https://github.com/ltcmweb/seedsigner/blob/3d321d50d488919d94fe407f538d8e655ba13eb4/src/seedsigner/hardware/buttons.py#L198

import threading
import socket


class MockGPIO:
LOW = 0
HIGH = 1
BOARD = 10
IN = 1
PUD_UP = 22
RPI_INFO = {"P1_REVISION": 3}

def __init__(self):
self._states = {}
self.lock = threading.Lock()
self._initialized = False

def setmode(self, mode):
pass

def setup(self, pin, mode, pull_up_down=None):
if not self._initialized:
self.init()

def init(self, socket_path="gpio.sock"):
if self._initialized:
return
self._initialized = True
self._states = {}
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(socket_path)
threading.Thread(target=self._read_loop, daemon=True).start()

def _read_loop(self):
f = self.sock.makefile("r")
while True:
line = f.readline()
if not line:
break
try:
pin, val = line.strip().split()
with self.lock:
self._states[int(pin)] = int(val)
except Exception:
pass

def input(self, pin):
with self.lock:
return self._states.get(pin, self.HIGH)
Loading