diff --git a/.gitignore b/.gitignore index 9c70709f3..5239a8763 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ src/seedsigner/models/settings_definition.json .coverage* *.po -*.mo \ No newline at end of file +*.mo +*.bmp diff --git a/README.md b/README.md index 171bd6713..1e5b273cd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/img/simulator.png b/docs/img/simulator.png new file mode 100644 index 000000000..b525bf908 Binary files /dev/null and b/docs/img/simulator.png differ diff --git a/docs/local_simulator_setup_instructions.md b/docs/local_simulator_setup_instructions.md new file mode 100644 index 000000000..9e804c80f --- /dev/null +++ b/docs/local_simulator_setup_instructions.md @@ -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. + + + +--- + +### 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. diff --git a/tools/emulator/patches/camera.py b/tools/emulator/patches/camera.py new file mode 100644 index 000000000..ed44a0b62 --- /dev/null +++ b/tools/emulator/patches/camera.py @@ -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 diff --git a/tools/emulator/patches/gpio.py b/tools/emulator/patches/gpio.py new file mode 100644 index 000000000..9f64bab1d --- /dev/null +++ b/tools/emulator/patches/gpio.py @@ -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) diff --git a/tools/emulator/patches/window.py b/tools/emulator/patches/window.py new file mode 100644 index 000000000..55d8f9923 --- /dev/null +++ b/tools/emulator/patches/window.py @@ -0,0 +1,206 @@ +# Forked and modified from @enteropositivo, @ltcmweb +# https://github.com/enteropositivo/seedsigner-emulator/blob/master/seedsigner/emulator/desktopDisplay.py +# https://github.com/ltcmweb/seedsigner/blob/3d321d50d488919d94fe407f538d8e655ba13eb4/src/gui.py + +import os +import time +import threading +import socket +import numpy as np +from tkinter import * +from PIL import Image, ImageTk + +import time +import numpy as np + + +class Window: + def __init__(self, HardwareButtons, width: int = 240, height: int = 240): + self.width = width + self.height = height + self.HardwareButtons = HardwareButtons + + self.setup_sock() + self.run() + + def run(self): + self.root = Tk() + self.root.title("SeedSigner") + + self.root.geometry(f"{self.width*2}x{self.height}+240+240") + self.root.resizable(False, False) + self.root.configure(bg="orange") + self.root.attributes("-topmost", True) + + self.label = Label(self.root) + self.label.pack() + + self.joystick = Frame(self.root) + self.joystick.pack() + self.joystick.place(x=20, y=85) + self.joystick.configure(bg="orange") + + pixel = PhotoImage(width=1, height=1) + + self.btnL = Button( + self.joystick, + image=pixel, + width=20, + height=20, + command=self.HardwareButtons.KEY_LEFT_PIN, + ) + self.btnL.grid(row=1, column=0) + self.bindButtonClick(self.btnL) + + self.btnR = Button( + self.joystick, + image=pixel, + width=20, + height=20, + command=self.HardwareButtons.KEY_RIGHT_PIN, + ) + self.btnR.grid(row=1, column=2) + self.bindButtonClick(self.btnR) + + self.btnC = Button( + self.joystick, + image=pixel, + width=20, + height=20, + command=self.HardwareButtons.KEY_PRESS_PIN, + ) + self.btnC.grid(row=1, column=1) + self.bindButtonClick(self.btnC) + + self.btnU = Button( + self.joystick, + image=pixel, + width=20, + height=20, + command=self.HardwareButtons.KEY_UP_PIN, + ) + self.btnU.grid(row=0, column=1) + self.bindButtonClick(self.btnU) + + self.btnD = Button( + self.joystick, + image=pixel, + width=20, + height=20, + command=self.HardwareButtons.KEY_DOWN_PIN, + ) + self.btnD.grid(row=2, column=1) + self.bindButtonClick(self.btnD) + + self.btn1 = Button( + self.root, + image=pixel, + width=40, + height=20, + command=self.HardwareButtons.KEY1_PIN, + ) + self.btn1.place(x=self.width + 160, y=60) + self.bindButtonClick(self.btn1) + + self.btn2 = Button( + self.root, + image=pixel, + width=40, + height=20, + command=self.HardwareButtons.KEY2_PIN, + ) + self.btn2.place(x=self.width + 160, y=116) + self.bindButtonClick(self.btn2) + + self.btn3 = Button( + self.root, + image=pixel, + width=40, + height=20, + command=self.HardwareButtons.KEY3_PIN, + ) + self.btn3.place(x=self.width + 160, y=172) + self.bindButtonClick(self.btn3) + + def key_handler(event): + if event.keysym == "Up": + self.key_press(self.HardwareButtons.KEY_UP_PIN) + if event.keysym == "Down": + self.key_press(self.HardwareButtons.KEY_DOWN_PIN) + if event.keysym == "Left": + self.key_press(self.HardwareButtons.KEY_LEFT_PIN) + if event.keysym == "Right": + self.key_press(self.HardwareButtons.KEY_RIGHT_PIN) + + if event.keysym in ("1", "KP_1"): + self.key_press(self.HardwareButtons.KEY1_PIN) + if event.keysym in ("2", "KP_2"): + self.key_press(self.HardwareButtons.KEY2_PIN) + if event.keysym in ("3", "KP_3"): + self.key_press(self.HardwareButtons.KEY3_PIN) + + if event.keysym == "Return": + self.key_press(self.HardwareButtons.KEY_PRESS_PIN) + + self.root.bind("", key_handler) + + self.periodic_update() + self.root.mainloop() + + def key_press(self, key): + self.set_input(key, 0) + time.sleep(0.1) + self.set_input(key, 1) + + def bindButtonClick(self, btn): + btn.bind("