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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.env
venv
env

__pycache__
display.bmp
105 changes: 104 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,104 @@
# seedsigner-emulator
# seedsigner-emulator

A standalone emulator for [SeedSigner](https://github.com/SeedSigner/seedsigner) that runs a GUI simulator for easy local development. You can interact with the buttons in the window or use keyboard controls: arrow keys, Enter, Numpad_1, Numpad_2, Numpad_3.

> **Note:** Only tested on macOS (26) using Homebrew and Python 3.14.

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

---

## Prerequisites

- A desktop / laptop with webcam
- An existing clone of the [SeedSigner repository](https://github.com/SeedSigner/seedsigner)

---

## Installing System Dependencies

SeedSigner's simulator depends on Python with `tkinter` support, and native libraries for QR scanning and camera access.

- `tkinter` is Python's built-in library for creating desktop GUI applications.
- `pyzbar` requires a system library 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 the Dev Environment

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

python3 -m venv env
source env/bin/activate

# Verify tkinter works (should open a small window)
python3 -m tkinter

pip install -r requirements.txt
```

---

## Camera Permissions (macOS)

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
```

Go to **System Settings → Privacy & Security → Camera** and enable access for:

- **Terminal** (if running from Terminal)
- **VS Code** (if running from VS Code)

Then restart Terminal and rerun the emulator.

If macOS didn't prompt you, reset permissions manually:

```bash
tccutil reset Camera
```

---

## Running the Emulator

Pass the path to your SeedSigner repository (or its `src/` directory directly):

```bash
python run_emulator.py /path/to/seedsigner
# or
python run_emulator.py /path/to/seedsigner/src
```

---

## Notes

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

---

## Credits

- Original work inspired by [enteropositivo/seedsigner-emulator](https://github.com/enteropositivo/seedsigner-emulator), the first working emulator POC using Tkinter-based replacements for camera, buttons, and renderer.
- Further macOS support with local GPIO socket emulation from [ltcmweb/seedsigner](https://github.com/ltcmweb/seedsigner), building on @enteropositivo's work.
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.
Empty file added patches/__init__.py
Empty file.
173 changes: 173 additions & 0 deletions 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 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