Skip to content

Commit ef7072c

Browse files
refactor and credits
1 parent de2fdff commit ef7072c

7 files changed

Lines changed: 598 additions & 562 deletions

File tree

docs/local_simulator_setup_instructions.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ source env/bin/activate
4242
# should not fail loading tkinter
4343
python3 -m tkinter
4444

45-
pip install -r requirements-simulator.txt
45+
pip install -r tools/emulator/requirements-simulator.txt
4646
```
4747

4848
#### Camera Permissions (macOS Security)
@@ -74,11 +74,16 @@ tccutil reset Camera
7474
### Running the Simulator
7575

7676
```bash
77-
python tools/run_emulator.py
77+
python tools/emulator/run_emulator.py
7878
```
7979

8080
### Notes
8181

8282
- The simulator environment is **not security-hardened** and should never be used with real funds.
8383
- Camera access, QR decoding, and GUI rendering are all platform-dependent; macOS quirks differ from Raspberry Pi OS.
8484
- For hardware deployment, follow the official Raspberry Pi OS instructions in the main project README.
85+
86+
### Credits
87+
88+
- 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.
89+
- The modified work from https://github.com/ltcmweb/seedsigner based on @enteropositivo's work added macOS support with local emulation of the GPIO socket.

tools/emulator/patches/camera.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Forked and modified from @enteropositivo
2+
# https://github.com/enteropositivo/seedsigner-emulator/blob/master/seedsigner/hardware/camera.py
3+
# https://github.com/enteropositivo/seedsigner-emulator/blob/master/seedsigner/emulator/webcamvideostream.py
4+
5+
from threading import Thread
6+
import time
7+
import cv2
8+
import numpy as np
9+
from PIL import Image
10+
11+
from seedsigner.models.settings import Settings, SettingsConstants
12+
from seedsigner.models.singleton import Singleton
13+
14+
15+
class MockCamera(Singleton):
16+
_video_stream = None
17+
_picamera = None
18+
_camera_rotation = None
19+
20+
@classmethod
21+
def get_instance(cls):
22+
# This is the only way to access the one and only Controller
23+
print("INSTANCE")
24+
if cls._instance is None:
25+
cls._instance = cls.__new__(cls)
26+
cls._instance._camera_rotation = int(
27+
Settings.get_instance().get_value(
28+
SettingsConstants.SETTING__CAMERA_ROTATION
29+
)
30+
)
31+
cls._instance._camera_rotation += 90
32+
return cls._instance
33+
34+
def start_video_stream_mode(
35+
self, resolution=(512, 384), framerate=12, format="bgr"
36+
):
37+
if self._video_stream is not None:
38+
self.stop_video_stream_mode()
39+
40+
try:
41+
self._video_stream = WebcamVideoStream(
42+
resolution=resolution, framerate=framerate, format=format
43+
)
44+
self._video_stream.start()
45+
except Exception as e:
46+
raise e
47+
48+
def read_video_stream(self, as_image=False):
49+
if not self._video_stream:
50+
raise Exception("Must call start_video_stream first.")
51+
frame = self._video_stream.read()
52+
if not as_image:
53+
return frame
54+
else:
55+
if frame is not None:
56+
return Image.fromarray(frame.astype("uint8"), "RGB").rotate(
57+
90 + self._camera_rotation
58+
)
59+
return None
60+
61+
def stop_video_stream_mode(self):
62+
if self._video_stream is not None:
63+
self._video_stream.stop()
64+
self._video_stream = None
65+
66+
def start_single_frame_mode(self, resolution=(720, 480)):
67+
if self._video_stream is not None:
68+
self.stop_video_stream_mode()
69+
if self._picamera is not None:
70+
self._picamera.close()
71+
72+
def capture_frame(self):
73+
print("CAPTURE")
74+
frame = WebcamVideoStream.single_frame()
75+
return Image.fromarray(frame).rotate(90 + self._camera_rotation)
76+
77+
def stop_single_frame_mode(self):
78+
if self._picamera is not None:
79+
self._picamera.close()
80+
self._picamera = None
81+
82+
83+
def resize_and_center_crop_240(frame_bgr: np.ndarray) -> np.ndarray:
84+
target = 240
85+
h, w = frame_bgr.shape[:2]
86+
87+
# resize to 240
88+
if w < h:
89+
scale = target / w
90+
else:
91+
scale = target / h
92+
93+
new_w = int(w * scale)
94+
new_h = int(h * scale)
95+
96+
resized = cv2.resize(frame_bgr, (new_w, new_h), interpolation=cv2.INTER_AREA)
97+
98+
# center crop
99+
x1 = (new_w - target) // 2
100+
y1 = (new_h - target) // 2
101+
102+
cropped = resized[y1 : y1 + target, x1 : x1 + target]
103+
104+
return cropped
105+
106+
107+
class WebcamVideoStream:
108+
def __init__(self, resolution=(320, 240), framerate=32, format="bgr", **kwargs):
109+
# initialize the camera
110+
self.camera = cv2.VideoCapture(0)
111+
self.set_resolution(resolution)
112+
113+
# initialize the frame and the variable used to indicate
114+
# if the thread should be stopped
115+
self.frame = None
116+
self.should_stop = False
117+
self.is_stopped = True
118+
119+
def start(self):
120+
# start the thread to read frames from the video stream
121+
t = Thread(target=self.update, args=())
122+
t.daemon = True
123+
t.start()
124+
self.is_stopped = False
125+
return self
126+
127+
def update(self):
128+
if self.camera.isOpened():
129+
# keep looping infinitely until the thread is stopped
130+
while not self.should_stop:
131+
# grab the frame from the stream and clear the stream in
132+
# preparation for the next frame
133+
ret, stream = self.camera.read()
134+
stream = cv2.resize(stream, (240, 240))
135+
stream = cv2.cvtColor(stream, cv2.COLOR_BGR2RGB)
136+
time.sleep(0.05)
137+
self.frame = stream
138+
self.is_stopped = True
139+
self.should_stop = False
140+
141+
def read(self):
142+
return self.frame
143+
144+
def stop(self):
145+
# indicate that the thread should be stopped
146+
self.should_stop = True
147+
148+
# Block in this thread until stopped
149+
while not self.is_stopped:
150+
pass
151+
152+
def set_resolution(self, resolution):
153+
self.camera.set(3, resolution[0])
154+
self.camera.set(4, resolution[1])
155+
156+
@staticmethod
157+
def single_frame():
158+
cap = cv2.VideoCapture(0)
159+
160+
# Warm-up, let auto-exposure settle in
161+
for _ in range(30):
162+
cap.read()
163+
time.sleep(0.01)
164+
165+
ret, frame = cap.read()
166+
cap.release()
167+
168+
if not ret:
169+
raise RuntimeError("Camera capture failed")
170+
171+
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
172+
frame = resize_and_center_crop_240(frame)
173+
return frame

tools/emulator/patches/gpio.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Forked and modifed from @ltcmweb
2+
# https://github.com/ltcmweb/seedsigner/blob/3d321d50d488919d94fe407f538d8e655ba13eb4/src/seedsigner/hardware/buttons.py#L198
3+
4+
import threading
5+
import socket
6+
7+
8+
class MockGPIO:
9+
LOW = 0
10+
HIGH = 1
11+
BOARD = 10
12+
IN = 1
13+
PUD_UP = 22
14+
RPI_INFO = {"P1_REVISION": 3}
15+
16+
def __init__(self):
17+
self._states = {}
18+
self.lock = threading.Lock()
19+
self._initialized = False
20+
21+
def setmode(self, mode):
22+
pass
23+
24+
def setup(self, pin, mode, pull_up_down=None):
25+
if not self._initialized:
26+
self.init()
27+
28+
def init(self, socket_path="gpio.sock"):
29+
if self._initialized:
30+
return
31+
self._initialized = True
32+
self._states = {}
33+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
34+
self.sock.connect(socket_path)
35+
threading.Thread(target=self._read_loop, daemon=True).start()
36+
37+
def _read_loop(self):
38+
f = self.sock.makefile("r")
39+
while True:
40+
line = f.readline()
41+
if not line:
42+
break
43+
try:
44+
pin, val = line.strip().split()
45+
with self.lock:
46+
self._states[int(pin)] = int(val)
47+
except Exception:
48+
pass
49+
50+
def input(self, pin):
51+
with self.lock:
52+
return self._states.get(pin, self.HIGH)

0 commit comments

Comments
 (0)