Initial skeleton — Phase 1 scaffolding

A browser-based companion for SeedHammer v1 hardware. Three coordinated
PWAs (composer, emulator, combined-sim) plus an optional Android wrapper,
inspired by Gangleri42's SeedHammer II fork but retargeted to v1's
Pi-Zero / WaveShare / MarkingWay hardware.

What's in this commit:

- LICENSE: Unlicense (matching upstream)
- README.md: project overview + status + roadmap
- CREDITS.md: upstream provenance + pinned baseline SHAs
- docs/architecture/: five authoritative design docs (lifted from
  the prep work in mineracks-infrastructure):
  * BASELINES.md — pinned SHAs, license audit, path-mapping table
  * v1-engrave-spec.md — MarkingWay USB-serial wire protocol audit
  * v1-buttons-and-ui.md — GPIO map, UI screen flow, keyboard map
  * sh1e-spec.md — composer-to-Pi envelope format spec
  * seedsigner-reuse.md — Pyodide strategy + jumbo (SeedSigner+) support
- Go package skeleton with doc.go contracts:
  backup, bezier, bspline, engrave, engrave/wire, engrave/wire/sh1e,
  font (+ comfortaa, poppins, constant), gui, input, internal/golden,
  platform/v1
- cmd/ entrypoints with stub main(): composer, emulator, combined-sim,
  seedsigner-sim
- web/ static-shell skeleton
- go.mod (module github.com/mineracks/seedhammer-v1-companion, Go 1.22)
- go build ./... + go vet ./... both clean

Next: lift universal packages (backup, font, bezier, bspline) from
upstream seedhammer/seedhammer at v1.3.0 verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mineracks 2026-05-28 18:25:03 +10:00
commit 3696dd6b34
34 changed files with 1961 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Go build artifacts
/bin/
*.exe
*.dll
*.so
*.dylib
# WASM build artifacts
*.wasm
*.wasm.map
wasm_exec.js
# Node / web build
node_modules/
.pnpm-store/
dist/
.parcel-cache/
# Pyodide bundle (large; downloaded at build time)
web/seedsigner-sim/pyodide/dist/
# Editor / OS
.DS_Store
.vscode/
.idea/
*.swp
*~
# Test artifacts
coverage.out
coverage.html
*.test
seedsigner-screenshots/
# Local dev
.env
.env.local
# go workspace overrides
go.work
go.work.sum

63
CREDITS.md Normal file
View File

@ -0,0 +1,63 @@
# Credits
This project stands on the shoulders of three communities. None of these
upstreams legally require attribution (the SeedHammer projects are released
under the Unlicense; SeedSigner is MIT). Attribution is kept here as
courtesy and to make code provenance traceable.
## Upstream SeedHammer (Pi-Zero / v1 firmware)
- **Repo:** https://github.com/seedhammer/seedhammer
- **Baseline:** v1.3.0 (commit `2f071c1d8f23eb7fd39b15fc0acb8874113f801e`)
- **License:** Unlicense (public domain dedication)
- **Lifted from this codebase:** plate-area constants (`backup/`), engrave-stroke
geometry (`engrave/`), MarkingWay USB-serial driver (`driver/mjolnir/`),
WaveShare HAT button mapping (`driver/wshat/`), pre-rasterised OpenType
fonts (`font/comfortaa`, `font/poppins`, `font/constant`), GUI screen flows
(`gui/`), camera + LCD drivers (`driver/libcamera/`, `driver/drm/`),
curve math (`bezier/`, `bspline/`), image helpers (`image/`).
## Gangleri42's SeedHammer fork (SH-II features that inspired this port)
- **Repo:** https://github.com/Gangleri42/seedhammer
- **Baseline:** branch `seedhammer-features` (commit `0a3c63efb125d17d8ec86ce739ecd058c8747cfe`)
- **License:** Unlicense (public domain dedication)
- **Lifted from this codebase:** composer PWA shell (`cmd/webnfc/`), firmware-in-browser
emulator pattern (`cmd/wasmemu/`), combined composer+emulator harness
(`cmd/webnfc-sim/`), Android wrapper structure (`cmd/seedhammer-android/`).
The engrave-payload encoder (`engrave/wire/`) is NOT lifted as-is — it is
SH-II-specific (the SH2E NFC envelope); v1 has a completely different live
USB-serial protocol. We replace it with a v1-shaped SH1E envelope (see
`docs/architecture/sh1e-spec.md`).
## SeedSigner (companion emulator)
- **Repo:** https://github.com/SeedSigner/seedsigner
- **Baseline:** TBD — bundled via Pyodide at a pinned commit; see
`web/seedsigner-sim/UPSTREAM.md` once that is wired up.
- **License:** MIT
- **Used in this project:** runtime UI assets (`src/seedsigner/resources/`),
custom icon font (`seedsigner-icons.otf`), screen-flow definitions
(`src/seedsigner/views/`, `src/seedsigner/gui/screens/`), screenshot
generator (`tests/screenshot_generator/`) used as a CI pixel-diff oracle,
3D-printable enclosure CAD source (`enclosures/`) for the device chassis
render.
- **License compliance:** SeedSigner-derived files segregated under
`web/seedsigner-sim/upstream/` and `cmd/seedsigner-sim/upstream/` with
the MIT notice preserved in `LICENSE.seedsigner`.
## Other dependencies
- **`github.com/fxamacker/cbor/v2`** — MIT — used for SH1E payload
encoding (canonical CBOR).
- **`github.com/tarm/serial`** — MIT — USB-serial driver, brought in via
upstream SeedHammer's `driver/mjolnir/`.
- **`periph.io/x/conn/v3`** + **`periph.io/x/host/v3`** — Apache 2.0 — GPIO
library, brought in via upstream SeedHammer's `driver/wshat/`.
- **Pyodide** — MPL-2.0 — Python-in-WASM runtime used to host the
SeedSigner emulator in-browser. See `web/seedsigner-sim/pyodide/`.
## Contributing
If you contribute and want your name listed here, just say so in your PR.
We will not list you without consent.

24
LICENSE Normal file
View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

90
README.md Normal file
View File

@ -0,0 +1,90 @@
# seedhammer-v1-companion
**Status:** early development. Phase 1 (composer port) in progress. See
[docs/architecture/](docs/architecture/) for the project plan and design
docs.
A browser-based companion for **SeedHammer v1** hardware (the original
Raspberry-Pi-Zero-based engraver), inspired by Gangleri42's SeedHammer II
fork. Ships three coordinated tools and one optional desktop wrapper.
## What this is
### 1. Plate composer (browser PWA)
Design a stainless steel seed-backup plate from your phone or desktop —
seed words, custom title text, optional logos — then transfer the design
to a real SeedHammer v1 controller via QR code.
The composer renders a pixel-faithful preview using the *same Go code the
Pi controller runs*. What you see in the browser is what the engraver
will physically punch.
The on-the-wire envelope is **SH1E** (a CBOR + CRC32 format documented at
[docs/architecture/sh1e-spec.md](docs/architecture/sh1e-spec.md)). Sized
to fit a 24-word multisig plate in a single QR frame, with BBQr fallback
for larger multi-plate manifests.
### 2. SeedHammer v1 emulator (browser PWA)
Run the real v1 controller firmware in your browser. The same `gui/`,
`input/`, and `engrave/` Go packages that drive the physical device,
compiled to WASM. Use it to:
- Test workflows end-to-end without a physical device
- Take screenshots / record screencasts of v1 flows
- Demo SeedHammer v1 to people without shipping hardware
Keyboard mapping: arrows = joystick, Enter = center/confirm, 1/2/3 = Button1/2/3.
### 3. Bundled SeedSigner emulator (browser PWA)
A faithful in-browser SeedSigner — both the classic 1.3" 240×240 model and
the newer 2.8" SeedSigner+ "jumbo" model. Generates seed-phrase QR codes
that you can hand off to the SeedHammer v1 emulator via a single button
press, end-to-end without leaving the page.
Built by hosting the upstream SeedSigner Python code via Pyodide so that
*the emulator IS the firmware* — when SeedSigner releases new versions
we bump the pinned commit and the sim updates.
### 4. Optional Android wrapper
Kotlin/Gradle shell hosting the composer WASM, for users who want a
plate-design app instead of a PWA. Mirrors the structure of Gangleri42's
SH2 Android companion.
## Hardware targeted
This codebase targets the **original SeedHammer v1** specifically — the
[Pi Zero v1.3 / WaveShare 1.3" 240×240 LCD HAT / MarkingWay engraver]
hardware. **Not** the newer SeedHammer II (RP2040 / TinyGo / SH2E NFC).
For an SH-II companion, use [Gangleri42's fork](https://github.com/Gangleri42/seedhammer)
directly. Most of the inspiration for this project comes from there.
## Status & roadmap
- ☐ **Phase 1** — composer port (Go-to-WASM, SH1E reference encoder, web UI)
- ☐ Phase 2 — v1 emulator (firmware-in-browser, Gangleri42-faithful UI shell)
- ☐ Phase 2.5 — SeedSigner emulator + QR handoff
- ☐ Phase 3 — combined three-pane sim
- ☐ Phase 4 — real-device validation on real v1 hardware
- ☐ Phase 5 (optional) — ColdCard emulator (port from Gangleri42's fork)
- ☐ Phase 6 (optional) — Android wrapper
## Building
(Will be documented as Phase 1 lands — `go build ./...` + a Vite build for
the web shells.)
## License
Released under the [Unlicense](LICENSE) (public domain dedication), matching
upstream SeedHammer's choice. SeedSigner-derived files segregated and
retain their MIT notice.
## Credits + provenance
Heavy lifting by three upstreams. See [CREDITS.md](CREDITS.md) for what
came from where and the pinned baseline commits.

19
backup/doc.go Normal file
View File

@ -0,0 +1,19 @@
// Package backup defines SeedHammer v1 plate dimensions and layout
// constants.
//
// Three plate types are supported, matching upstream's stainless plate SKUs:
//
// SmallPlate 85 × 55 mm — single seed (12 or 24 words)
// SquarePlate 85 × 85 mm — single seed + title
// LargePlate 85 × 134 mm — seed + descriptor for multisig
//
// The engraver's origin sits 97mm in the X axis from the plate edge (a
// fixed offset of the physical machine). outerMargin = 3 mm and
// innerMargin = 10 mm define the engrave-safe area.
//
// All constants are mm; the engrave/ package converts to machine steps
// (1 step ≈ 0.00796 mm) at command-generation time.
//
// Status: STUB — to be lifted verbatim from upstream's backup/backup.go
// at v1.3.0. The constants are stable across all v1.x releases.
package backup

10
bezier/doc.go Normal file
View File

@ -0,0 +1,10 @@
// Package bezier provides quadratic and cubic Bezier curve tessellation
// for the engrave pipeline.
//
// Used to convert OpenType glyph QuadTo/CubeTo segments and SH1E SVG path
// Q/C commands into linear MoveTo/LineTo sequences at the engraver's
// resolution.
//
// Status: STUB — to be lifted from upstream at v1.3.0 + cross-checked
// against Gangleri42's fork (the math is hardware-agnostic and unchanged).
package bezier

7
bspline/doc.go Normal file
View File

@ -0,0 +1,7 @@
// Package bspline provides B-spline tessellation for the engrave pipeline.
//
// Used by the OpenType font rasteriser for the few glyphs that use
// B-spline rather than Bezier segments.
//
// Status: STUB — to be lifted from upstream at v1.3.0.
package bspline

19
cmd/combined-sim/doc.go Normal file
View File

@ -0,0 +1,19 @@
// Command combined-sim wires composer + emulator + SeedSigner-sim into a
// single browser page with a QR-handoff bus.
//
// The handoff bus has two modes:
//
// - Display mode (faithful): a sim renders a QR on its canvas; the
// SeedHammer emulator's mock camera reads pixels from that canvas
// and runs them through the upstream QR decoder. Same code path as
// a real device.
//
// - Direct mode (fast): a shared JS bus copies the payload between sims
// directly, skipping QR encode/decode. Useful for debugging.
//
// Status: STUB — depends on cmd/composer, cmd/emulator, cmd/seedsigner-sim
// landing first.
//
// Modelled on Gangleri42's cmd/webnfc-sim:
// https://github.com/Gangleri42/seedhammer/tree/seedhammer-features/cmd/webnfc-sim
package main

11
cmd/combined-sim/main.go Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "combined-sim: not yet implemented — depends on composer + emulator + seedsigner-sim landing first")
os.Exit(2)
}

24
cmd/composer/doc.go Normal file
View File

@ -0,0 +1,24 @@
// Command composer is the browser-side plate composer for SeedHammer v1.
//
// Compiles to WebAssembly via GOOS=js GOARCH=wasm. The HTML/CSS shell that
// hosts this WASM lives under web/composer/.
//
// Responsibilities:
// - Render a real-time preview of the plate using the same engrave + font
// code the Pi controller runs (code-identity guarantees preview = reality).
// - Emit an SH1E envelope (see docs/architecture/sh1e-spec.md) that the
// Pi controller can scan via QR code.
//
// Exports four JS functions, modelled on Gangleri42's cmd/webnfc:
//
// seedhammerEncodeText(plateType uint8, blocks []TextBlock) []byte // SH1E
// seedhammerPreviewText(plateType uint8, blocks []TextBlock) string // SVG preview
// seedhammerEncodeSVG(plateType uint8, paths []SvgPath) []byte // SH1E
// seedhammerPreviewSVG(plateType uint8, paths []SvgPath) string // SVG preview
//
// Status: STUB — implementation lifted progressively from
// https://github.com/Gangleri42/seedhammer/tree/seedhammer-features/cmd/webnfc
// at pinned baseline 0a3c63efb125d17d8ec86ce739ecd058c8747cfe, with the
// SH-II geometry block replaced by v1 constants from upstream
// seedhammer/seedhammer v1.3.0 (backup/backup.go).
package main

11
cmd/composer/main.go Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "composer: not yet implemented — see docs/architecture/sh1e-spec.md and cmd/composer/doc.go")
os.Exit(2)
}

24
cmd/emulator/doc.go Normal file
View File

@ -0,0 +1,24 @@
// Command emulator runs the SeedHammer v1 controller firmware in a browser.
//
// Compiles to WebAssembly via GOOS=js GOARCH=wasm. The HTML/CSS shell that
// hosts this WASM lives under web/emulator/.
//
// The WASM hosts the real v1 gui/, input/, and engrave/ packages, with
// hardware-side drivers (driver/wshat for buttons, driver/drm for the LCD,
// driver/libcamera for the camera, driver/mjolnir for the engraver)
// replaced by browser-side mocks:
//
// - Buttons → keyboard events (arrows / Enter / 1, 2, 3)
// - LCD → HTML5 canvas
// - Camera → mock that reads QRs from sibling panes on the same page
// - Engraver → null sink (preview mode) or visual playback animation
//
// Status: STUB — implementation will lift upstream v1.3.0 gui/ and input/
// packages, with browser-side platform adapters in platform/v1/.
//
// Modelled architecturally on Gangleri42's cmd/wasmemu at:
// https://github.com/Gangleri42/seedhammer/tree/seedhammer-features/cmd/wasmemu
// (pinned at 0a3c63efb125d17d8ec86ce739ecd058c8747cfe), but his wasmemu
// emulates SH-II's touch UI — v1 is button-driven, so we rebuild from scratch
// while keeping his PWA shell pattern.
package main

11
cmd/emulator/main.go Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "emulator: not yet implemented — see docs/architecture/v1-buttons-and-ui.md and cmd/emulator/doc.go")
os.Exit(2)
}

16
cmd/seedsigner-sim/doc.go Normal file
View File

@ -0,0 +1,16 @@
// Command seedsigner-sim is a tiny Go helper that builds the SeedSigner
// emulator's static web assets.
//
// The actual emulation is NOT done in Go — it's done by hosting the
// upstream SeedSigner Python codebase in the browser via Pyodide. See
// docs/architecture/seedsigner-reuse.md for the architecture.
//
// This Go binary's job:
// - Download a pinned Pyodide release tarball
// - Pull the upstream SeedSigner Python source at a pinned commit
// - Verify checksums
// - Lay them out under web/seedsigner-sim/dist/ for the static-site build
//
// Status: STUB — implementation pending. Pinned commit choices documented
// in web/seedsigner-sim/UPSTREAM.md once that file exists.
package main

View File

@ -0,0 +1,11 @@
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "seedsigner-sim: not yet implemented — Pyodide build helper, see docs/architecture/seedsigner-reuse.md")
os.Exit(2)
}

19
docs/README.md Normal file
View File

@ -0,0 +1,19 @@
# Documentation
## architecture/
Authoritative design docs for the project. These are the contract — every
implementation decision should be traceable back to one of these.
| File | What it answers |
|---|---|
| [BASELINES.md](architecture/BASELINES.md) | Which exact upstream SHAs this project is built on top of, license findings, path-mapping table for the v1.0.0 → v1.3.0 layout refactor. |
| [v1-engrave-spec.md](architecture/v1-engrave-spec.md) | The MarkingWay USB-serial wire protocol used by upstream v1 — opcodes, framing, plate geometry, font format. |
| [v1-buttons-and-ui.md](architecture/v1-buttons-and-ui.md) | v1's physical button layout, GPIO mapping, screen-flow conventions, proposed emulator keyboard map. |
| [sh1e-spec.md](architecture/sh1e-spec.md) | The new on-the-wire envelope format for shipping plate designs from composer to controller (CBOR + CRC32, deterministic encoding, security analysis). |
| [seedsigner-reuse.md](architecture/seedsigner-reuse.md) | Strategy for bundling a faithful in-browser SeedSigner emulator via Pyodide + supporting both Classic and SeedSigner+ "jumbo" device profiles. |
All five docs were drafted as prep before this repo existed — they originate
in `mineracks-infrastructure/roadmap/seedhammer-v1-companion/prep/`. The
copies here are the live authoritative versions; the prep docs are now
historical.

View File

@ -0,0 +1,181 @@
# Project baselines (frozen 2026-05-28)
These are the immutable starting points for the SeedHammer v1 companion port.
Always rebase against these exact SHAs unless deliberately bumping the
baseline — and if bumping, append a new dated section below, don't
overwrite this one.
## Upstream v1 (seedhammer/seedhammer @ v1.3.0)
| | |
|---|---|
| Tag | `v1.3.0` |
| Tag object SHA | `70ed718c797663beeb65d323340e3853fca7920f` |
| **Commit SHA** | **`2f071c1d8f23eb7fd39b15fc0acb8874113f801e`** |
| Repo | https://github.com/seedhammer/seedhammer |
| Why this tag | **Verified 2026-05-28**: v1.3.0 is the latest tag still targeting v1 hardware (Pi Zero / WaveShare 1.3" LCD HAT / mjolnir USB-serial engraver). All five v1.x tags (v1.0.0 through v1.3.0) are v1-hardware; v1.4.x+ are SeedHammer II (RP2040 / TinyGo / SH2E wire protocol). See "Baseline bump 2026-05-28" section below for the verification evidence. |
### Other available v1.x tags
All verified v1-hardware on 2026-05-28; later v1.x tags supersede earlier
ones with bug fixes and a directory-layout refactor (see bump note below).
```
v1.0.0 → 6f9aa7ac017db3978703e596c77e7919c7fdaa8d (original baseline; superseded)
v1.1.1 → 5b0265deae09117e9949ec8a1b76cc0ccc7bded0 (v1-hardware, pre-refactor layout)
v1.2.0 → 595a8c04574a6694e791be269710c3e81a7bab0a (v1-hardware, post-refactor layout)
v1.2.1 → f5c41e3a1b4ba815dfdee726d11b702d7cc4bc22 (v1-hardware, post-refactor layout)
v1.3.0 → 2f071c1d8f23eb7fd39b15fc0acb8874113f801e ← chosen baseline
```
### Baseline bump 2026-05-28: v1.0.0 → v1.3.0
Verified by direct GitHub-tree + raw-content inspection of all five v1.x
tags. **All are v1 hardware.** Evidence:
- `go.mod` at v1.3.0 still imports `github.com/tarm/serial` (v1 USB-serial
engraver lib) and `periph.io/x/conn/v3` + `periph.io/x/host/v3`
(BCM283x GPIO lib, used by `bcm283x.GPIO6/19/5/26/13/21/20/16` for the
WaveShare HAT joystick + 3 keys — same pin map as v1.0.0).
- `flake.nix` at v1.3.0 references only `raspberrypi` (kernel, firmware,
camera modules 1/2/3, Pi Zero 2). **No TinyGo, no RP2040, no pico**.
- README at v1.3.0 explicitly: *"It runs on the same hardware as the
SeedSigner: Raspberry Pi Zero or Zero W, a WaveShare 1.3 inch 240x240
LCD hat and a Pi Zero compatible camera with a OV5647 sensor."*
- `driver/mjolnir/driver.go` at v1.3.0: package comment *"driver for the
MarkingWay engraving machine"*, still uses `tarm/serial`.
- No `picobin/`, `uf2/`, `cmd/picosign`, `cmd/biptool`, `engrave/wire/`,
or `nfc/` at any v1.x tag — all are SeedHammer II markers.
**Layout-refactor caveat (lift-path updates needed):** v1.2.0 reorganised
the tree without changing hardware. The "lift from upstream" paths below
have been updated to the v1.3.0 layout. v1.0.0 → v1.3.0 path mapping:
| v1.0.0 path | v1.3.0 path |
|---|---|
| `input/input.go` | `driver/wshat/wshat.go` |
| `mjolnir/driver.go`, `mjolnir/sim.go` | `driver/mjolnir/driver.go`, `driver/mjolnir/sim.go` |
| `lcd/lcd_linux.go` | `driver/drm/drm_linux.go` |
| `camera/camera_linux.{cpp,go,h}` | `driver/libcamera/camera_linux.{cpp,go,h}` |
| `rgb16/`, `ninepatch/` | `image/rgb565/`, `image/ninepatch/` |
| `cmd/controller/platform_linux.go` | `cmd/controller/platform_rpi.go` |
Top-level diff: v1.0.0 has 229 tree entries, v1.3.0 has 285. Net adds:
`driver/{drm,libcamera,mjolnir,wshat}`, `image/{alpha4,image.go,ninepatch,paletted,rgb565}`,
`font/{bitmap,vector}`, `bip39/{gen.go,wordlist.go}`. Removed: `affine/`,
`camera/`, `input/`, `lcd/`, `mjolnir/` (top-level), `ninepatch/`, `rgb16/`
(all subsumed by the refactor). go.mod adds `github.com/kortschak/qr`
(replaces `skip2/go-qrcode`) and `decred/dcrec/secp256k1/v4` as direct dep.
The button-pin map (`GPIO 6/19/5/26/13/21/20/16` for Up/Down/Left/Right/
Center/Button1/Button2/Button3) is **identical byte-for-byte** between
v1.0.0 and v1.3.0 — just moved file. Likewise the mjolnir wire protocol
constants (`StrokeWidth: 38, Millimeter: 126`) are unchanged.
**Recommendation taken**: bump to v1.3.0. Rationale: same hardware, three
extra release-cycles of upstream bug-fixes, no risk of v2 transition
code (none exists at any v1.x tag), and the post-refactor layout is what
any future upstream cherry-picks will use.
## Gangleri42 fork (Gangleri42/seedhammer @ seedhammer-features)
| | |
|---|---|
| Branch | `seedhammer-features` |
| HEAD SHA | `0a3c63efb125d17d8ec86ce739ecd058c8747cfe` |
| HEAD date | 2026-05-22 |
| Repo | https://github.com/Gangleri42/seedhammer |
### Fork-point note
The fork's history is a **rewrite**, not a fork-from-tip. GitHub's `compare`
API returns 404 / null between upstream `main` and this branch because they
share no common ancestor near tip. Treating it as a separate codebase to
cherry-pick from, not a branch to merge.
Lifting strategy:
1. Identify the files / packages we want from `seedhammer-features` at the
pinned HEAD (see [v1-engrave-spec.md](v1-engrave-spec.md) and
[v1-buttons-and-ui.md](v1-buttons-and-ui.md))
2. Copy them as-is into the new repo with attribution + license preserved
3. Rebind the v1-incompatible bits (geometry constants, wire format,
button mapping) per the prep docs
## Key files we plan to lift from Gangleri42 fork
(All paths relative to fork root at the pinned HEAD)
- `cmd/webnfc/` — composer PWA shell + Go-to-WASM entry
- `cmd/wasmemu/` — firmware-in-browser emulator (UI shell will be reused;
the WASM contents must be rebuilt against v1's `gui/` package)
- `cmd/webnfc-sim/` — combined composer + emulator
- `cmd/coldcard-sim/`, `cmd/coldcard-wasm/` — optional ColdCard emu
- `engrave/wire/`**NOT** lifted directly; this is SH-II's SH2E format.
v1 has a completely different live-USB-serial protocol (see
[v1-engrave-spec.md](v1-engrave-spec.md))
- `bezier/`, `bspline/`, `font/sh/`, `backup/`, `internal/golden/` — lift
as-is; these are geometry/font math, hardware-agnostic
- `gui/gridedit.go`, `gui/precompiled.go` — composer GUI helpers, lift
as-is if they remain decoupled from the v2 `gui/` package state
## Key files we plan to lift from upstream v1.3.0
(All paths relative to upstream root at the pinned commit
`2f071c1d8f23eb7fd39b15fc0acb8874113f801e`. If you cross-reference older
prep docs that still cite v1.0.0 paths, see the path-mapping table in the
"Baseline bump 2026-05-28" section above.)
- `driver/wshat/wshat.go` — v1 button + GPIO mapping (was `input/input.go`
at v1.0.0; see [v1-buttons-and-ui.md](v1-buttons-and-ui.md))
- `gui/` — the v1 controller UI (Linux/Pi-side, full Go, not TinyGo)
- `driver/mjolnir/driver.go`, `driver/mjolnir/sim.go` — v1 engraver driver
+ sim (was `mjolnir/...` at v1.0.0; see [v1-engrave-spec.md](v1-engrave-spec.md))
- `driver/drm/drm_linux.go` — v1 LCD output (was `lcd/lcd_linux.go` at v1.0.0)
- `driver/libcamera/` — v1 camera (was `camera/` at v1.0.0)
- `font/comfortaa`, `font/poppins`, `font/constant` — v1 fonts
(pre-rasterised OpenType); v1.3.0 also adds `font/bitmap` + `font/vector`
- `image/rgb565/`, `image/ninepatch/` — moved from top-level `rgb16/` +
`ninepatch/` in the v1.2.0 refactor
- `backup/backup.go` — v1 plate-size constants (SmallPlate / SquarePlate / LargePlate)
## License audit (verified 2026-05-28)
Both repos use the **Unlicense** (public-domain dedication), not MIT as
originally assumed. Confirmed by fetching `LICENSE` from each pinned ref:
- Upstream `seedhammer/seedhammer` @ `v1.3.0` — Unlicense
- https://raw.githubusercontent.com/seedhammer/seedhammer/v1.3.0/LICENSE
- File header: *"This is free and unencumbered software released into
the public domain."* with reference to https://unlicense.org
- `Gangleri42/seedhammer` @ `seedhammer-features` — Unlicense
- https://raw.githubusercontent.com/Gangleri42/seedhammer/seedhammer-features/LICENSE
- Byte-identical to upstream's LICENSE file (Gangleri42 inherited it).
Implications for the new repo:
- **No attribution legally required.** Both upstreams have explicitly
dedicated their work to the public domain.
- We will **still** preserve `CREDITS.md` and inline file-level attribution
blocks anyway, as a matter of good practice and community courtesy. The
goal of this port is to *amplify* the original projects, not to obscure
their origin.
- The new repo will adopt **Unlicense** as well, for full pipeline
compatibility and zero friction for downstream forks.
For the SeedSigner assets we plan to bundle (see
[seedsigner-reuse.md](./seedsigner-reuse.md)), the upstream
`SeedSigner/seedsigner` repo is MIT-licensed. That requires us to keep the
MIT attribution alongside any SeedSigner assets we ship. Mixed-license
shipping is fine — we'll segregate SeedSigner-derived files under a
clearly-labelled subdirectory with the MIT notice retained.
## How to use this file
- Pin every PR against these SHAs (call them out in the PR body).
- If we deliberately bump:
- Don't edit the section above. Add a new section below dated and
cross-link old → new.
- Re-run the prep checks (engrave spec + button layout) against the
new SHAs in case anything changed.
- These SHAs are also the input to a deterministic `flake.nix` or
`vendor.txt` pin if we adopt one.

View File

@ -0,0 +1,202 @@
# SeedSigner emulator — reuse strategy + jumbo-variant support
This doc covers two scope additions to the SeedSigner sim portion of the
project (Phase 2.5 in the project plan):
1. Reuse of existing SeedSigner artwork and screen flow definitions
2. Support for the "jumbo" SeedSigner+ variant in addition to classic
## TL;DR
- Build the SeedSigner sim by **rendering upstream firmware code directly in
the browser via Pyodide** — bundle the official `SeedSigner/seedsigner@dev`
Python with the official screenshot generator's display-mock pattern,
switch the ST7789 driver for a canvas writer. ~3-4 days for a functional
build, +1-2 days for chassis art.
- This gets us **pixel-faithful screens for free** because we're literally
running the upstream Python UI code.
- One firmware, two device profiles — Classic 240×240 portrait and
SeedSigner+ 320×240 landscape. Same buttons, same flows, just a different
`(driver, w, h)` tuple. No code-fork needed.
- All reusable artwork (icons, fonts, logo, chassis CAD) is MIT-licensed
upstream — no permission needed.
## Existing SeedSigner emulators (community survey)
| Project | Lang/Runtime | Browser? | License | Verdict |
|---|---|---|---|---|
| `SeedSigner/seedsigner` `tests/screenshot_generator/` | Python+Pillow | No (headless) | MIT | **Reuse pattern + use as CI test oracle** |
| `enteropositivo/seedsigner-emulator` | Python+tkinter | Desktop, no | **No license** | Reference architecture only, don't copy code |
| `v4ires/seedsigner-emulator` | Fork of above | Desktop, no | No license | Same |
| `SeedSigner/seedsigner-settings-generator` | HTML+JS static | Yes | MIT | Useful as **packaging template** |
| Monero forks (`DiosDelRayo`, `Monero-HackerIndustrial`) | Python+tkinter | No | Various | Pattern reference only |
**Key finding:** No browser-based SeedSigner emulator exists. We're in
greenfield. But the upstream screenshot generator at
`SeedSigner/seedsigner@dev:tests/screenshot_generator/generator.py` is
exactly the architecture we want — it runs the full Python UI with a
mocked ST7789 driver and renders to PNG. Reuse its `MockedDisplay` pattern,
swap PNG output for a `<canvas>` writer via Pyodide.
## Architecture decision: Pyodide vs Go-WASM port
For the SeedHammer composer/emulator the language is Go (compiles to WASM
natively). For SeedSigner the language is Python.
Three options for SeedSigner sim:
| Option | Effort | Faithfulness | Bundle size | Maintenance |
|---|---:|---|---:|---|
| (a) Behavioural-only Go port — just emit the right QR formats | 2d | None — no UI | Tiny | Drift risk on every upstream UI change |
| (b) Hand-rebuild SeedSigner UI in JS/WASM from scratch | 7-10d | Best-effort | Medium | High — every upstream change needs porting |
| (c) **Bundle Pyodide + upstream Python verbatim** | 3-4d | **Identical to real device** | ~5MB gzipped | **Zero drift — bump upstream commit, done** |
**Recommendation: (c)** for the SeedSigner sim, given the user's stated
preference for "great result that matches reality". The 5MB Pyodide bundle
is a one-time download (cached forever in PWA), and we get bit-perfect
fidelity to whatever upstream ships. When upstream releases v0.9.0, we
bump the commit, the sim updates automatically.
This is *the same architecture choice* we made for the SeedHammer composer
(reuse upstream `engrave/`+`font/` packages directly), just expressed in
Python land via Pyodide instead of Go via WASM.
## Reusable assets (all MIT — direct use OK)
### Runtime UI assets
Paths relative to `SeedSigner/seedsigner@dev`:
- `src/seedsigner/resources/icons/``arrow-up`, `arrow-down`, `back`,
`btc_logo`, `btc_logo_30x30`, `btc_logo_bw`, `dire_warning`, `warning`
(plus `_selected` variants), PNG
- `src/seedsigner/resources/img/``btc_logo_60x60.png`, `logo_black_240.png`
- `src/seedsigner/resources/fonts/` — OpenSans, Inconsolata, NotoSans,
FontAwesome Free Solid, **`seedsigner-icons.otf`** (custom icon font)
### Branding
- `docs/img/logo.svg` — official SeedSigner logo
- `docs/img/Mini_Pill_Main_Photo.jpg`, `Orange_Pill.JPG`,
`Open_Pill_*.JPG`, `Open_Pill_w_Comfort_Joystick.png` — hardware photos
### Chassis CAD (best for rendering emulator frame)
- `enclosures/open_pill/``.f3d` Fusion 360 source + `.stl`
- `enclosures/orange_pill/``.f3d` + `.stl` for Upper, Lower, button, joystick
- `enclosures/orange_pill_mini/`, `open_pill_mini[_w_coverplate]/`,
`look_screws_pill/`, `pushcase/`
**For SeedSigner+ chassis:** no upstream CAD exists. Sold by Go Brrr at
gobrrr.me. Either:
- render a generic landscape-form-factor chassis ourselves
- contact Go Brrr for permission to use their product render
- ship without chassis art initially (just the screen frame), add chassis
later if Go Brrr provides one or someone in the community models it
## Behavioural spec — also upstream
The most valuable thing we get from Pyodide-bundle-upstream is that
**`src/seedsigner/views/` + `src/seedsigner/gui/screens/` define every
screen flow in the device**. There's no need to re-document or
reverse-engineer flows — they're MIT-licensed code we run directly.
## The screenshot generator as CI oracle
We can wire `pytest tests/screenshot_generator/generator.py` into our
emulator's CI: render every canonical screen with the mocked display,
hash the PNGs, then have our browser emulator render the same screens
via Pyodide and compare. Any pixel divergence = regression.
This is a **stronger** correctness story than any community emulator has.
## Jumbo / SeedSigner+ variant
### Hardware (verified 2026-05-28)
| Field | Classic | SeedSigner+ (jumbo) |
|---|---|---|
| Screen | 1.3" 240×240 | **2.8" 240×320 (rendered as 320×240 landscape)** |
| Driver chip | `st7789` | `st7789` *or* `ili9341` (Go Brrr doesn't publish which; either is supported by firmware) |
| Pi board | Pi Zero v1.3 | **Same — Pi Zero v1.3** |
| Camera | OV5647 (Pi Zero camera) | **Same — OV5647-class** |
| Buttons | 5-way joystick + 3 keys | **Same — identical input pins + semantics** |
| Touchscreen | No | **No** (not in firmware, not in product) |
Released in upstream **v0.8.6 "The Bigger Picture"** (2025-06-30).
Sold by Go Brrr as **"SeedSigner+"** — ~€19 enclosure / ~€130 assembled.
Also available as **"Battery Powered SeedSigner+"** with AAA battery pack.
A 3.5" ILI9486 480×320 variant is **declared but not implemented** in
firmware (`raise Exception("ILI9486 display not implemented yet")`). So a
true jumbo-jumbo exists on the roadmap — we'll plumb for it but won't
expose the profile until upstream lands the driver.
### Firmware reality
**Single codebase, multiple device profiles** — selection happens in
`src/seedsigner/hardware/displays/display_driver.py` via
`DisplayDriverFactory.instantiate_display_driver(display_type, width, height)`.
For the emulator that means **one Python bundle, three (eventually four)
device profiles**:
```yaml
profiles:
- id: classic
label: "SeedSigner Classic (1.3\" 240×240)"
driver: st7789
width: 240
height: 240
orientation: portrait
- id: plus
label: "SeedSigner+ (2.8\" 320×240)"
driver: st7789
width: 320
height: 240
orientation: landscape
- id: plus-ili9341
label: "SeedSigner+ ILI9341 panel variant"
driver: ili9341
width: 320
height: 240
orientation: landscape
# - id: jumbo-3-5
# label: "SeedSigner 3.5\" (planned, not yet supported by firmware)"
# driver: ili9486
# width: 480
# height: 320
# orientation: landscape
# disabled: true
# disabled_reason: "Upstream firmware ili9486 driver not yet implemented"
```
The browser emulator's UI gets a profile selector dropdown. Switching
profiles re-initialises the Pyodide-side `DisplayDriverFactory` with the
new tuple and re-renders. Buttons remain identical across all profiles.
## Items to verify on real hardware
1. Whether the SeedSigner+ panel's actual driver IC is ST7789 or ILI9341.
Go Brrr doesn't publish it; firmware accepts either. Our emulator
supports both as separate profiles.
2. Panel orientation handling — firmware reports 240×320 native and renders
320×240 landscape. Visually verify on a real device that left-edge of
the rendered image matches the physical "top" of the screen as the user
holds it.
3. STL licensing for the SeedSigner+ enclosure if we want a chassis render.
Currently appears Go-Brrr-proprietary.
## Open questions for project plan
1. **License compatibility for Pyodide bundling.** Pyodide itself is MPL-2.0
licensed. The Python wheels we include depend on each package's license.
Need to audit: SeedSigner deps + transitive deps for any GPL.
2. **Bundle size budget.** A baseline Pyodide bundle is ~10MB raw / ~3-5MB
gzipped. Plus SeedSigner code + assets. Need to measure and ensure PWA
caches sensibly. First load slower; subsequent loads instant.
3. **Camera/QR emulation in Pyodide.** SeedSigner reads QR codes via
PiCamera2/OpenCV. We need to wire the browser's `<canvas>`-based QR
handoff to the SeedSigner Python's camera API stub. This is doable but
isn't trivial. Likely a Python shim that monkey-patches
`seedsigner.hardware.camera`.
These three are not blockers for Phase 2.5 starting but should be flagged
upfront so we don't discover them mid-build.

View File

@ -0,0 +1,355 @@
# SH1E — SeedHammer v1 Engrave envelope format
**Status:** draft v0.1, 2026-05-28
**Stability:** unstable — the format will change as we validate it against
real hardware. Pin the version byte; any breaking change bumps it.
SH1E is the on-the-wire format that ships a plate design from the
browser-side composer to a SeedHammer v1 Pi controller, intended primarily
for transport via QR code(s) scanned by the Pi's camera. Secondary
transport channels (USB stick, HTTP) work without modification.
The Pi side parses SH1E, runs the parsed design through its **existing
trusted rasteriser** (`engrave/` + `font/` + `backup/` packages), shows
the user a preview on the LCD, and waits for hold-to-confirm before
streaming the resulting 10-byte commands to the engraver via `mjolnir/`.
The composer side emits SH1E for transport AND uses the same Go code in
WASM to render an in-browser preview that matches what the Pi will produce
**by code-identity**, not by re-implementation. See the [project plan
doc](./BASELINES.md) for the broader architecture.
## Design goals
1. **Intent, not commands.** SH1E describes what to engrave, not how. The
trusted Pi controller rasterises locally; the composer cannot directly
drive the engraver bytes. This keeps the security boundary in the right
place.
2. **Single-frame QR friendly.** A 24-word plate fits in one QR (≤ ~1100
bytes for QR version 24 with low EC). Larger designs degrade to
multi-frame BBQr or animated QR.
3. **Self-describing.** Magic + version + length + CRC32 — any reader can
detect "this is SH1E", reject the wrong version cleanly, and detect
corruption from bit-flips in the QR.
4. **Canonical encoding.** Same design → same bytes → same QR. Users who
want to verify "the QR I'm scanning is the design I intended" can
reproduce it.
5. **Easy to parse safely.** Deterministic CBOR with strict ranges. No
recursive structures, no length-prefix tricks, no implicit defaults
that could be spoofed.
## Envelope
```
+----------+----------+--------------+--------+-----------+
| magic | version | payload_len | crc32 | payload |
| 4 bytes | 1 byte | 2 bytes (LE) | 4 bytes| N bytes |
| "SH1E" | 0x01 | 0x0000- | | |
| | | 0xFFFF | | |
+----------+----------+--------------+--------+-----------+
```
- **`magic`** = ASCII `SH1E` (0x53 0x48 0x31 0x45). Constant.
- **`version`** = current `0x01`. Any other value rejected by readers.
- **`payload_len`** = exact byte count of `payload`, little-endian uint16.
Max 65535 bytes. (Reality budget: a single-frame QR fits ~1500 bytes
of binary data; we'd never approach the uint16 ceiling.)
- **`crc32`** = CRC-32/ISO-HDLC (poly `0xEDB88320`, init `0xFFFFFFFF`,
refin/refout true, xorout `0xFFFFFFFF`) computed over the `payload`
bytes only (NOT magic/version/length). Little-endian uint32.
- **`payload`** = CBOR-encoded `Design` (see below). RFC 8949 deterministic
encoding only.
Total minimum overhead: **11 bytes** of envelope.
## Payload structure (Design)
The `Design` is a CBOR map. All fields are required unless marked
optional. Top-level keys are short integers (deterministic encoding rule:
integer keys sort before string keys; integer keys are encoded in their
smallest form).
```cbor
{
1: <uint> # plate_type — see "Plate types" below
2: [<TextBlock>], # text_blocks (array, len 0..32)
3: [<SvgPath>], # svg_paths (array, len 0..16, OPTIONAL — omit if empty)
4: <bytes>(32) # design_fingerprint — SHA-256 of canonical-encoded fields 1-3, used as a stable design ID
}
```
### Plate types (integer enum, matches `backup/backup.go` order)
| Value | Name | Dimensions (mm) | Source |
|---:|---|---|---|
| 0 | `SmallPlate` | 85 × 55 | `backup.SmallPlate` |
| 1 | `SquarePlate` | 85 × 85 | `backup.SquarePlate` |
| 2 | `LargePlate` | 85 × 134 | `backup.LargePlate` |
### TextBlock
```cbor
{
1: <uint> # font_id — see "Font IDs"
2: <uint> # size — point size, 1..200 (clamped Pi-side)
3: <int> # x_mm — millimetres from plate origin, signed int (origin offset handled Pi-side)
4: <int> # y_mm — millimetres from plate origin
5: <uint> # alignment — 0 Left | 1 Center | 2 Right
6: <tstr> # text — UTF-8, but Pi-side limited to ASCII (rejects non-ASCII at parse)
7: <uint> # rotation — 0, 90, 180, 270 degrees only (OPTIONAL, default 0)
}
```
**Text length cap:** 256 chars per block (UTF-8 bytes, before ASCII check).
**Combined design cap:** 32 text blocks max.
### Font IDs (matches upstream v1 fonts at the pinned baseline)
| Value | Name | Source path |
|---:|---|---|
| 0 | `Comfortaa` | `font/comfortaa` |
| 1 | `Poppins` | `font/poppins` |
| 2 | `Constant` | `font/constant` |
If we lift additional fonts in a later baseline, the enum extends but
**never renumbers**.
### SvgPath
For logos, custom marks, embedded designs. The Pi parses these to its
internal segment representation (`MoveTo` / `LineTo` / `QuadTo` /
`CubeTo`) — same shape as the font glyph code already handles.
```cbor
{
1: <int> # x_mm — anchor X
2: <int> # y_mm — anchor Y
3: <uint> # scale_pct — percentage 1..1000 (clamped Pi-side)
4: <tstr> # path_d — SVG `d` attribute string, validated against a strict subset
5: <uint> # rotation — 0/90/180/270 (OPTIONAL, default 0)
}
```
**SVG path subset (strict):**
- Commands allowed: `M`, `L`, `H`, `V`, `Q`, `C`, `Z` (and lowercase relatives `m`, `l`, `h`, `v`, `q`, `c`, `z`)
- No arcs (`A`/`a`) — they require curve approximation that's easier to do composer-side
- No `S`/`T` shortcuts — composer must emit explicit `Q`/`C` instead
- Coordinates: decimal numbers with optional leading sign, no scientific
notation, no comma separators, single-space-separated. (Stricter than
full SVG but trivially parseable in Go.)
- Max path length: 4096 chars
- Max number of points: 1024
## Canonical encoding rules
Per RFC 8949 § 4.2.1, with these additional clarifications:
1. Map keys sorted by integer value ascending.
2. Integers in their shortest CBOR form (tiny ints inline, then uint8,
uint16, uint32, uint64 as needed).
3. Text strings encoded as UTF-8 with no BOM.
4. Byte strings have explicit length prefix.
5. No indefinite-length items.
6. No tags (no CBOR semantic tags used in this spec).
7. Floating point not used anywhere (all numerics are integers).
**Two encodings of the same Design MUST produce byte-identical payload.**
This is enforceable by the composer using a known-canonical CBOR library
(e.g. `fxamacker/cbor` with `EncOptions{Sort: SortBytewiseLexical}`).
## QR transport
A single SH1E envelope:
| Design complexity | Approx envelope size | Recommended QR |
|---|---:|---|
| 12-word seed, SmallPlate, single font block | ~150 B | QR version 6, M ECC, alphanumeric mode |
| 24-word seed, LargePlate, 2 font blocks | ~280 B | QR version 10, M ECC, byte mode |
| 24-word seed + multisig descriptor, LargePlate, 4 font blocks | ~500 B | QR version 17, M ECC, byte mode |
| Above + small SVG logo | ~700 B | QR version 22, M ECC |
| Multi-plate manifest (3 plates) | 1.5-2 KB | **BBQr or animated QR** |
For multi-frame transport, use **BBQr** (https://github.com/coinkite/BBQr)
as it's already supported by ColdCard / Sparrow / Specter and we'd get
ecosystem compatibility for free. Don't invent a new chunking format.
The Pi controller scans BBQr or single QR transparently — the camera
flow on v1 already understands both via its existing QR reader.
## Examples
### Minimal: 12-word seed on SmallPlate, single font block
```json5
{
1: 0, // plate_type = SmallPlate
2: [
{
1: 0, // font_id = Comfortaa
2: 12, // size = 12pt
3: 5, // x_mm
4: 5, // y_mm
5: 0, // alignment = Left
6: "ABANDON ABILITY ABLE ABOUT ABOVE ABSENT ABSORB ABSTRACT ABSURD ABUSE ACCESS ACCIDENT"
}
],
4: <32-byte SHA-256>
}
```
CBOR encoding of this: approximately 130 bytes (depends on the exact
seed words used).
### Two-block layout with logo
```json5
{
1: 1, // plate_type = SquarePlate
2: [
{ // title
1: 1, // font_id = Poppins
2: 18,
3: 10, 4: 8,
5: 1, // Center
6: "MY MULTISIG KEY 1 OF 3"
},
{ // seed words
1: 0, // font_id = Comfortaa
2: 12,
3: 5, 4: 25,
5: 0,
6: "WORD1 WORD2 WORD3 ... WORD24"
}
],
3: [
{ // BTC logo top-right
1: 70, 2: 5,
3: 100, // 100% scale
4: "M 0 0 L 10 0 L 10 10 L 0 10 Z M 2 2 L 8 8 M 8 2 L 2 8"
}
],
4: <32-byte SHA-256>
}
```
## Parser validation rules (Pi side)
The Pi-side parser **must** reject a SH1E envelope if any of these hold,
without further processing:
1. `magic``SH1E`
2. `version``0x01`
3. `payload_len` doesn't match actual payload byte length
4. `crc32` doesn't match recomputed CRC
5. Payload is not canonical CBOR (sort order wrong, indefinite-length used,
floats present, unknown tag present)
6. Required key missing (1, 2, or 4 absent)
7. Any unknown integer key in any map (forwards-compat: unknown string
keys allowed and ignored, unknown integer keys MUST be rejected — this
reserves int keys for future-version expansion)
8. `plate_type` not in `{0, 1, 2}`
9. `font_id` references a font not bundled in the running firmware
10. `text` contains a non-ASCII codepoint (because v1 fonts are ASCII-only)
11. Any numeric field outside its documented range
12. Total text block count > 32 or any text length > 256 bytes
13. Total SVG path count > 16 or any path string > 4096 chars
14. `design_fingerprint` doesn't match a recomputed SHA-256 of fields 1-3
15. Resulting plate layout exceeds plate dimensions (with the
`outerMargin = 3 mm` from `backup.go` applied)
16. Total command stream would exceed the engraver's safe operating envelope
After validation passes, the Pi:
- Renders a preview on the LCD using the existing rasterisation pipeline
- Shows total command count + estimated engrave time
- Waits for hold-to-confirm on Button3 (the existing engrave-confirm pattern)
- Streams the resulting commands via `mjolnir/`
## Forward compatibility
Two extension dimensions:
1. **Bumping version byte** (`0x01 → 0x02`) — used for breaking changes
(e.g. coordinate system change, new envelope structure). Old firmware
rejects, prompting user to update.
2. **Adding new integer keys** to the maps — used for non-breaking
additions (e.g. new optional `kerning` field on `TextBlock`). Old
firmware **rejects** unknown integer keys (per rule 7 above) so a
composer using v0.2 keys can target v0.1 readers only by omitting them.
3. **String keys** are reserved for **forward-compat hints** — old readers
ignore them. Use sparingly and never for security-relevant data (since
they're ignored).
## Security analysis
### Threat model
The Pi controller is the trusted compute base. The composer is untrusted —
could be malicious, could have bugs, could be served from a tampered CDN.
### What SH1E protects against
- **Wrong content engraved silently:** the Pi rasterises locally and shows
a preview the user must confirm. A malicious composer cannot bypass.
- **Out-of-range coordinates damaging the machine:** parser rule 15 +
range checks on every numeric.
- **Buffer-overflow style attacks:** strict size caps on all variable-length
fields, CBOR parser used must be vetted for buffer safety.
- **Confused-deputy via SVG complexity:** strict subset, hard caps on
path string length and point count.
- **Bit-flip corruption in QR:** CRC32 catches single-byte flips with
~99.9999998% probability.
### What SH1E does NOT protect against
- **Social engineering** to engrave the wrong seed. The Pi preview is
the user's last line of defence. They must read it carefully.
- **Pre-image or collision attacks on `design_fingerprint`.** It's
SHA-256 over canonical bytes — strong enough that any practical
collision is computationally infeasible.
- **Malicious upstream firmware.** Out of scope — that's a wider problem.
### Fuzzing requirement
Before any release ships with SH1E support, the Pi-side parser MUST be
fuzz-tested. Recommend `go-fuzz` or Go 1.18+ native fuzzing for at least
1 CPU-week against the parser entry point.
## Open questions
1. **Should we use `engrave/wire/`-style envelope for binary symmetry with
SH2E?** Currently no — SH2E is a *post-rasterised command stream*, SH1E
is a *design intent*. They serve different purposes. But the magic
prefix `SH1E` parallels `SH2E` deliberately.
2. **Should the Pi sign acknowledgement of a successful engrave back via
QR?** Useful for multi-plate flows (composer can verify which plate
was actually written). Out of scope for v0.1 — add in v0.2 if there's
user demand.
3. **What about hardware-bound designs?** A user could optionally embed
the iDRAC/serial number of their specific SeedHammer v1 in the design
to prevent "wrong physical device played the wrong design". Probably
overkill. Out of scope for v0.1.
4. **Should we support custom fonts uploaded as part of the design?** No.
That dramatically expands the attack surface (font parsers are
notoriously bug-prone) and breaks the "rasterise with trusted Pi
code" property. If users want a new font, propose upstream and we
ship it in firmware.
## Implementation references
- CBOR library (Pi side, Go): `github.com/fxamacker/cbor/v2` — already
used elsewhere in the seedhammer codebase
- CBOR library (composer side, Go-WASM): same package, compiled to WASM
- CRC32 (Go): `hash/crc32.MakeTable(crc32.IEEE)` — standard library
- BBQr (Go): port from `github.com/coinkite/BBQr` Python reference, or
use an existing Go BBQr lib if one exists at implementation time
## Sources
- Upstream `backup/backup.go` for plate dimensions (pinned in
[BASELINES.md](./BASELINES.md))
- Upstream `mjolnir/driver.go` for engraver wire details (see
[v1-engrave-spec.md](./v1-engrave-spec.md))
- RFC 8949 (CBOR)
- RFC 1952 (CRC-32 variant matches; see also CRC catalogue at
reveng.sourceforge.io for `CRC-32/ISO-HDLC`)
- BBQr spec at https://github.com/coinkite/BBQr

View File

@ -0,0 +1,296 @@
# SeedHammer v1 — buttons + UI flow
Source pinned to upstream tag `v1.0.0` of `github.com/seedhammer/seedhammer`
(commit `6f9aa7a`, dated 2023-06-29). All file references are to that tag.
## Hardware
- **Display:** Waveshare 1.3" 240×240 LCD HAT (ST7789-based). Confirmed in the
package comment of `input/input.go:1-2`:
> "package input implements an input driver for the joystick and buttons on
> the Waveshare 1.3" 240x240 HAT."
- **Camera:** OV5647 (per existing project knowledge — `cmd/controller/main.go`
comment notes "in the same configuration as SeedSigner", which uses OV5647).
- **Pi board:** Raspberry Pi Zero (per `cmd/controller/main.go:1-2`
"It runs on a Raspberry Pi Zero, in the same configuration as SeedSigner.").
SeedSigner v1 used Pi Zero v1.3 (camera-cable variant); SeedHammer ships the
same SKU.
- **Buttons:** 8 physical inputs total = 5-way joystick + 3 keys. GPIO mapping
confirmed from both the SeedHammer source and the Waveshare wiki — they match
exactly. See table below.
## Physical layout (ASCII sketch)
Looking at the HAT mounted on top of the Pi, with the 240×240 LCD facing the
operator: the joystick sits at the lower-left corner and the three keys form a
vertical column on the lower-right.
```text
+-----------------------------------+
| |
| |
| 240x240 LCD |
| |
| |
| |
| |
| ^ |
| | |
| <-- (o) --> [KEY1] |
| | [KEY2] |
| v [KEY3] |
| |
+-----------------------------------+
```
The joystick is a 5-way: up, down, left, right, press-in (Center). KEY1/KEY2/KEY3
are momentary tactile switches. All eight inputs are active-low with pull-ups
enabled in firmware (`input/input.go:53` — `btn.Pin.In(gpio.PullUp, gpio.BothEdges)`).
## Code-level button names
All eight inputs are exposed as constants of type `input.Button`
(`input/input.go:19-31`). The order of the iota matters because the controller's
debug `input <name>` command (`cmd/controller/debug.go:62-94`) and the GUI
code branch on these constants.
| Physical input | Go identifier | BCM GPIO | Source |
|-----------------------|-------------------|----------|------------------------------|
| Joystick Up | `input.Up` | 6 | `input/input.go:42` |
| Joystick Down | `input.Down` | 19 | `input/input.go:43` |
| Joystick Left | `input.Left` | 5 | `input/input.go:44` |
| Joystick Right | `input.Right` | 26 | `input/input.go:45` |
| Joystick Press | `input.Center` | 13 | `input/input.go:46` |
| Key 1 (top) | `input.Button1` | 21 | `input/input.go:47` |
| Key 2 (middle) | `input.Button2` | 20 | `input/input.go:48` |
| Key 3 (bottom) | `input.Button3` | 16 | `input/input.go:49` |
| (debug-only) Rune | `input.Rune` | — | `input/input.go:29` |
| (debug-only) Screenshot| `input.Screenshot`| — | `input/input.go:30` |
`Rune` and `Screenshot` are synthetic events emitted only from the debug build
(`cmd/controller/debug.go`); on real hardware only the eight physical inputs fire.
The Waveshare wiki's Pinout table at
<https://www.waveshare.com/wiki/1.3inch_LCD_HAT> matches this list 1:1 (KEY1=P21,
KEY2=P20, KEY3=P16, Up=P6, Down=P19, Left=P5, Right=P26, Press=P13), which means
the v1 firmware is using the HAT's stock wiring — no custom board, no jumpers.
Event delivery model (`input/input.go:33-77`): one goroutine per pin, 10 ms
debounce, sends `Event{Button, Pressed bool}` on a channel. The GUI layer adds
a derived `Click` flag (`Pressed=false` transition after a `Pressed=true`) —
that's what most screens key off via `e.Click`.
## Button-role conventions across the GUI
Reading every `switch e.Button` in `gui/gui.go` (and `input.Button*` references —
~50 of them), the v1 firmware uses a strikingly consistent role assignment:
- **Joystick Up/Down** — scroll a list, move a selection up/down a column,
move keyboard cursor up/down a row.
- **Joystick Left/Right** — page navigation (Receive↔Change addresses,
Singlesig↔Multisig on the main screen), and left/right cursor inside the
on-screen keyboard.
- **Joystick Center** — synonym for `Button3` ("primary confirm") in most
screens. Explicit examples:
- `gui/gui.go:2096``case input.Center, input.Button3:` (keyboard rune select)
- `gui/gui.go:1666``case input.Button2, input.Center:` (Confirm-Seed edit)
- `gui/gui.go:2231``case input.Button3, input.Center:` (engrave next-step)
- `gui/gui.go:2462``case input.Button3, input.Center:` (main screen select)
- **Button1 (top)** — Back / cancel. Renders with `assets.IconBack`.
- **Button2 (middle)** — Secondary action (Edit, Info, Flip-camera). On the
Engrave screen it's the press-and-hold "dry run" arming key
(`gui/gui.go:1241-1247`).
- **Button3 (bottom)** — Primary action / confirm / next. Renders with
`assets.IconCheckmark` or `assets.IconRight`. Press-and-hold to engrave
(`gui/gui.go:1248-1255`, `confirmDelay`).
These conventions are not declared in one place — they emerge from how
`layoutNavigation` is called with `Style: StyleSecondary` (B1, B2) vs
`Style: StylePrimary` (B3). The pattern is consistent enough that the emulator
can mirror it without per-screen overrides.
## Main UI screen flow (high-level)
Entry point is `cmd/controller/main.go`, which constructs `gui.NewApp(...)` and
loops on `a.Frame()` forever. The app owns a single `MainScreen`
(`gui/gui.go:2311-2335, 2674-2702`); every other screen is a transient child
mounted on the MainScreen's fields (`scanner`, `desc`, `seed`, `engrave`,
`warning`, etc.) and unmounted when its `Layout` returns done.
```text
boot
|
v
+--------------+
| MainScreen | page = singleKey | multiKey
| (carousel) | Left/Right: change page
+--------------+ Center/B3: Select()
|
+--------+---------+
| |
page == singleKey page == multiKey
| |
v v
+-------------+ +---------------+
| SeedScreen | | ScanScreen | (camera + QR)
| (enter 12/ | | Scan wallet |
| 24 words) | | output desc |
+-------------+ +---------------+
| |
| v
| +-------------------+
| | DescriptorScreen |
| | (shows xpubs; |
| | loops over each |
| | signer's seed) |
| +-------------------+
| |
| v
| +-------------------+
| | SeedScreen | per-signer
| +-------------------+
| |
+---------+--------+
v
+-----------------+
| EngraveScreen | step-by-step
| (Connect Mjolnir| instructions; B3
| → align → cut) | hold-to-engrave;
+-----------------+ B2 hold = dry run;
| B1 = back/cancel.
v
complete --> back to MainScreen
```
Screen-by-screen button table (only the screens an emulator user will see in
the first 5 minutes; QR-scan and shamir flows omitted):
| Screen | Up/Down | Left/Right | Center / B3 | B1 (Back) | B2 | Source |
|-----------------------|--------------------|-------------------|------------------------------|-------------------|---------------------|-----------------------|
| `MainScreen` | — | switch page | confirm `Select()` | — | — | `gui/gui.go:2456-2494`|
| `ChoiceScreen` (12/24)| change choice | — | confirm choice | back | — | `gui/gui.go:~1585` |
| `WordKeyboardScreen` | cursor row | cursor column | type letter | back | (delete word?) | `gui/gui.go:2043-2099`|
| `SeedScreen` (confirm)| select word | — | (B3) confirm seed | back (or discard) | (B2/Center) edit | `gui/gui.go:1653-1703`|
| `DescriptorScreen` | — | — | (B3) proceed | (B1) back | (B2) addresses | `gui/gui.go:475-495` |
| `AddressesScreen` | scroll page | Receive↔Change | — | (B1) close | — | `gui/gui.go:246-269` |
| `ScanScreen` | — | — | (B3) accept | (B1) back | (B2) flip-camera | `gui/gui.go:610-625` |
| `EngraveScreen` | — | — | (B3) hold-to-engrave | (B1) prev/cancel | (B2) hold for dry-run| `gui/gui.go:1227-1263`|
| `ConfirmWarningScreen`| — | — | (B3) hold to confirm | (B1) decline | — | `gui/gui.go:864-870` |
| `ErrorScreen` | — | — | (B3) dismiss | — | — | `gui/gui.go:794-805` |
Two interaction nuances the emulator must replicate:
1. **Hold-to-confirm.** `EngraveScreen` and `ConfirmWarningScreen` distinguish
`e.Pressed` (key down) from `e.Click` (full down-up cycle). They start a
`confirmDelay` countdown on press and complete the action only if the key is
still held when the timeout fires (`gui/gui.go:1248-1255`). A browser
emulator must therefore expose press-down and press-up as separate events,
not just keypress.
2. **Idle screensaver.** `App.Frame` (`gui/gui.go:2706-2717`) shows a screensaver
after 3 min of no input and "eats" the first button press to wake. The
emulator should mirror this (or at least not break it) to keep behaviour
true.
## Proposed emulator keyboard mapping
The v1 hardware has exactly 8 buttons + 2 debug synthetics, all available on a
standard desktop keyboard:
| Browser key | Maps to | Notes |
|-----------------------|--------------------|---------------------------------------------|
| `ArrowUp` | `input.Up` | Joystick up |
| `ArrowDown` | `input.Down` | Joystick down |
| `ArrowLeft` | `input.Left` | Joystick left |
| `ArrowRight` | `input.Right` | Joystick right |
| `Enter` | `input.Center` | Joystick press-in. Most "confirm" is here. |
| `1` | `input.Button1` | Top key (Back / cancel) |
| `2` | `input.Button2` | Middle key (Secondary / dry-run) |
| `3` | `input.Button3` | Bottom key (Primary confirm / hold-to-act) |
| `s` (shift+S) | `input.Screenshot` | Debug-only on hardware; useful in emulator |
| typing a letter AZ | `input.Rune` | Debug `runes` shortcut equivalent |
Recommended secondary aliases (no conflicts):
- `Escape``input.Button1` (universal "back" muscle memory).
- `Space``input.Button3` (universal "confirm" muscle memory; works with
hold-to-confirm naturally because keydown/keyup map cleanly).
- `w/a/s/d` → Up/Left/Down/Right (gamer convention; optional, not default).
The emulator must emit **down and up** events separately. Browser model:
`keydown``Pressed: true`, `keyup``Pressed: false`. The 10 ms hardware
debounce can be skipped in the emulator since the OS already debounces.
Auto-repeat must be suppressed for keys that drive hold-to-confirm
(`event.repeat` filter on `keydown`), otherwise Button3 will fire
`Pressed: true` repeatedly and the GUI's `confirm.Start(...)` will never settle.
## v2 / Gangleri42 reference mapping (for comparison)
Important finding: Gangleri42's `cmd/wasmemu/` is **not a v1 emulator**. From
`cmd/wasmemu/keyboard.go:11-21` and the visible `cmd/wasmemu/index.html` header
("SeedHammer II — firmware emulator", 480×320 canvas):
> "SeedHammer II is a touch device — this is the primary input path on real
> hardware (see processTouch in cmd/controller/platform_sh2.go). [...] Touch is
> the only navigation input; keyboard exists solely for the NFC-tap shortcut."
The wasmemu binds `seedhammerTouch(x, y, pressed)` (mouse on canvas) for
navigation, and binds digit keys `1``9`, `0`, `e`, `q`, `w` to **NFC-tap
payload shortcuts** — not to UI buttons. The full payload-key table from
`cmd/wasmemu/index.html`:
| Key | NFC payload |
|-----|----------------------------------------------------------|
| 1 | BIP-39 12-word |
| 2 | BIP-39 24-word |
| 3 | P2WSH 2-of-3 multisig |
| 4 | P2SH 2-of-3 multisig |
| 5 | singlesig (bare xpub) |
| 6 | BlueWallet JSON multisig |
| 7 | NIP-19 nsec |
| 8 | NIP-19 npub |
| 9 | codex32 share A (2-of-3) |
| 0 | codex32 unshared |
| e | CUSTOM block text |
| q | unknown format (rejection) |
| w | compound Nostr (rejection) |
**Implication for our v1 emulator design:** there is no prior-art keyboard map
to copy. We get to pick the v1 scheme cleanly. The proposed mapping above
(arrow keys + Enter + 1/2/3) reuses the digit keys for button-press in a way
that **does not collide** with the v2 emu's NFC shortcuts — because v1 has no
NFC. If we later build a combined v1+v2 emulator, the keys 1/2/3 will need to
context-switch based on whether the focused device is v1 (UI button) or v2
(NFC payload). Easier solution: reserve a different family of keys (e.g.,
F1/F2/F3) for v1 buttons if the combined emu happens. For a v1-only emu, the
proposed mapping above is correct.
We could optionally borrow Gangleri42's `seedhammerSynthTapText` /
`seedhammerSynthTapNDEF` pattern (untyped JS bridge globals) to bolt on a
"paste a mnemonic" debug helper in the v1 emulator that auto-types into the
`WordKeyboardScreen` via `input.Rune` events — same mechanism v1 already uses in
`cmd/controller/debug.go` `runes` command.
## Open questions
- **Long-press semantics.** v1 firmware reads `confirmDelay` from the GUI
package; we should pin the exact value (likely 1.5 s — see
`gui/gui.go` reference to `confirmDelay`). The emulator needs to mirror it
exactly or hold-to-confirm "feels off". Worth grepping for the const in a
follow-up pass.
- **Screensaver.** Should the emulator implement the 3-min idle screensaver,
or is it a distraction? Probably skip in the browser — most demos last <3 min
and the "eat first wake-press" behaviour confuses screencasting.
- **Camera substitute.** v1 `ScanScreen` expects a live OV5647 frame. The
emulator will need a stub camera (likely a canned QR-bytes injector, mirroring
Gangleri42's `seedhammerSynthTap` but for QR not NFC). Out of scope for this
doc; track separately.
- **Engraver substitute.** v1 `EngraveScreen` writes to a serial port spoken to
the Mjolnir engraver. The debug build already wires `mjolnir.NewSimulator()`
(`cmd/controller/debug.go:18-21`) — reuse this in the wasm build by tagging
appropriately.
- **Letter input.** `WordKeyboardScreen` uses Up/Down/Left/Right to drive a
4-row on-screen keyboard. Should the emulator also accept direct A-Z typing
via `input.Rune` (already supported in debug builds), or should it force the
user through the joystick to faithfully reproduce hardware UX? Suggest both:
default to faithful joystick, expose a "type words" debug helper for
productivity.

View File

@ -0,0 +1,323 @@
# SeedHammer v1 engrave wire format
Reverse-engineered by reading the upstream Go controller source at the v1.0.0
tag. This is the protocol the Raspberry Pi Zero speaks over USB-serial to the
MarkingWay engraving machine.
## Source
- Upstream repo: `github.com/seedhammer/seedhammer`
- Tag: **v1.0.0** (commit `6f9aa7a`, released 29 June 2024)
- Why v1.0.0 and not v1.4.x: from v1.4.0 onward the upstream repo became the
SeedHammer II firmware. v1.0.0 is the last tag that is unambiguously the
original v1 hardware (Pi Zero + MarkingWay engraver + the same
configuration as SeedSigner; see `cmd/controller/main.go:1-3`).
Key files:
| Path | LOC | Role |
|---|---|---|
| `mjolnir/driver.go` | 1418 | The complete engraver wire driver. Opens the serial port, runs the init/program/finish state machine, defines `Program.Move` / `Program.Line`. |
| `mjolnir/sim.go` | 1204 | Reference simulator. Independently confirms every opcode and the response sequence — treat it as the canonical decoder. |
| `engrave/engrave.go` | 11217 | `Program` interface (`Move(image.Point)` / `Line(image.Point)`); rasterises text, QR (constant-time and standard), shapes into Move/Line. |
| `font/font.go` | 178 | Glyph format: pre-decoded OpenType segments (MoveTo/LineTo/QuadTo/CubeTo) with float32 coords, ASCII-only index. |
| `backup/backup.go` | 180 | Plate size table and outer/inner margins (mm). |
| `gui/gui.go` | 10861130 | Where `mjolnir.Engrave(...)` is actually called: per-side `Program{DryRun}` constructed, glyph commands streamed through `Engrave(...)`. |
The driver package is named **mjolnir** (Thor's hammer) — that's the v1
codename for the engraver subsystem; do not look for a package called
`engraver` or `driver`.
## Transport
USB-serial via [`github.com/tarm/serial`](https://github.com/tarm/serial).
Connection params hard-coded in `mjolnir/driver.go:44-83`:
| Setting | Value |
|---|---|
| Baud | **115200** |
| Word length | 8 |
| Stop bits | 1 |
| Parity | none |
| Flow control | none (handshake=0, replace=0) |
| Xon limit | 2048 |
| Xoff limit | 512 |
Device path:
- Linux (Pi Zero): `/dev/ttyUSB0`, falls back to `/dev/ttyUSB1`.
- Windows (dev/test): `COM3`.
The MarkingWay engraver therefore presents to the Pi as a USB-serial CDC
device. There is no GPIO/SPI involvement at the controller-to-engraver layer
(the Pi's GPIO is used only for the LCD/buttons inside the SeedSigner-style
case, not for engraver I/O).
Handshaking is **command/response at the application layer**, not at the
serial layer. Every controller-initiated command produces a 1-or-more-byte
echo or status from the engraver (see opcode table). There is no XON/XOFF or
RTS/CTS; the controller-side buffer is `bufio.NewWriterSize(dev,
progBatchSize*cmdSize) = 80*10 = 800 bytes`.
## Wire format
**Binary, fixed-width, command-tagged.** No framing, no checksum, no
length prefix, no ACK byte. The receiver is a simple byte-driven state
machine that knows how many bytes follow each opcode.
### Two distinct phases
1. **Control phase.** Variable-length commands with immediate echo. Used
for init, set speed, set delays, move-to-origin, query position, and to
enter program mode.
2. **Program phase.** Fixed 10-byte commands ("draw commands"), streamed
in **batches of 80 commands (800 bytes)** without per-command ACK. The
engraver sends a 1-byte status byte after each batch (`bufferProgramStatus
0x60`) requesting the next batch, plus one `programStepStatus 0x6f` per
completed step and a final `programCompleteStatus 0x6a`. From
`mjolnir/driver.go:108-109` and `:240-305`.
### Draw command layout (program phase, 10 bytes)
From `mjolnir/driver.go:370-380` (`mkcoords`) and `:394-418`
(`Move` / `Line`):
```
byte 0 : opcode (0x80 = MoveTo / pen up, 0x00 = LineTo / pen down)
bytes 1..3 : X coordinate, 24-bit little-endian unsigned
bytes 4..6 : Y coordinate, 24-bit little-endian unsigned
bytes 7..9 : Z coordinate, 24-bit little-endian unsigned (always 0 in v1)
```
Concrete example: move pen to (10 mm, 5 mm). With `Millimeter = 1/0.00796 ≈
125.628`, 10 mm = 1256 ≈ `0x0004E8`, 5 mm = 628 ≈ `0x000274`. On the wire:
```
80 E8 04 00 74 02 00 00 00 00
```
LineTo to the same point: `00 E8 04 00 74 02 00 00 00 00`.
Coordinate range: `0` to `0xFFFFFF` (24-bit unsigned). Negative coords
panic (`driver.go:372-374`). Z is reserved — the controller always sends
zero and the simulator never reads it (`sim.go:80-84` only parses X and Y).
Padding: if a batch isn't full, the controller pads with **`0xFF`** bytes
(NOP, `nopCmd`). The engraver treats `0xFF` as a no-op in program mode
(`sim.go:179-180`). The padding is critical: omit it and the engraver
won't emit the completion status (`driver.go:243-245` — "Otherwise, the
engraver won't send a completed status").
### Opcode table
All values from `mjolnir/driver.go:85-106`. Echo/response columns from
`sim.go:86-119` and the `expect(...)` calls in `driver.go:185-220`.
| Opcode | Name | Args (bytes after opcode) | Reply | Phase | Notes |
|---|---|---|---|---|---|
| `0x00` | `initCmd` | none in control phase; in program phase the leading `0x00` is `lineCmd` and is followed by 9 coord bytes | After init: a status byte loop (see status table) | Control / Program | Same byte serves two purposes depending on phase. `sim.go:149-157` makes this explicit. |
| `0x16` | (un-named "query position") | none | Echoes `0x16`, then 9 coord bytes (X/Y/Z each 24-bit LE) | Control | Defined in `driver.go:215-220` but the function is bound to `_` — present in the protocol, not used by v1.0.0 firmware. |
| `0x21` | `moveToOriginCmd` | 1 byte: `0x50` (`moveToOriginCmdExtra`) | Echoes `0x21 0x00` (`moveToOriginCmdResponse`) | Control | "Reset origin to current physical position" (`driver.go:322-327`). Called twice in `Engrave`: before and after the needle-warmup pass. |
| `0x30` | `setSpeedCmd` | 6 bytes: print(LE16), move(LE16), `xxx`(LE16). Speed range `[1000, 30]` where **lower = faster** | Echoes `0x30` | Control | `driver.go:226-229`. v1 always passes `xxx = 0xE6` (230) — purpose undocumented, possibly a Z-axis or acceleration parameter. Called three times in `Engrave`: 300/300 for warmup, then user move/print speeds, then 300/300 for the post-engrave move-to-end. |
| `0x31` | `setDelaysCmd` | 2 bytes: penDown delay, penUp delay (0255) | Echoes `0x31` | Control | `driver.go:232-235`. v1 hard-codes `setDelays(0x14, 0x14)` = (20, 20). |
| `0x60` | `initProgramCmd` | 2 bytes: nbatches (LE16). Each batch = 80 × 10-byte commands = 800 bytes | None directly — engraver then drives the program-phase loop via status bytes | Control → Program | `driver.go:250` and `:171-174` in the sim. nbatches must be ≤ 0xFFFF (program-too-large guard at `driver.go:246-249`). |
| `0x80` | `moveCmd` | 9 coord bytes (X/Y/Z LE24) | None (batched) | Program | Pen-up move. |
| `0x00` | `lineCmd` | 9 coord bytes (X/Y/Z LE24) | None (batched) | Program | Pen-down draw / "hammer along path". Note this is the same byte as `initCmd`; phase-disambiguated. |
| `0xAF` | `cancelCmd` | none | Engraver transitions through `cancellingStatus` to `cancelledStatus` | Any | Sent on the quit channel (`driver.go:147`). |
| `0xFF` | `nopCmd` | 9 ignored bytes (treated as filler) | None (batched) | Program | Pad byte for incomplete batches. |
### Status byte table (engraver → controller)
From `driver.go:99-106` and `sim.go:108-119`. These are always **single
bytes** read by the controller; there's no length prefix.
| Status | Name | When sent | Controller reaction |
|---|---|---|---|
| `0x00` | `initializedStatus` | After `initCmd` succeeds | Exit init loop. |
| `0x60` | `bufferProgramStatus` | When the engraver's command buffer is ready for the next 80-command batch | Send 80 × 10-byte commands (with 0xFF padding if needed). |
| `0x62` | `cancellingStatus` | After receiving `cancelCmd`, before stopping | Wait. |
| `0x65` | `cancelledStatus` | Cancel complete | Set `ErrCancelled`. During init the controller responds by re-sending `initCmd` (`driver.go:200-206`). |
| `0x6A` | `programCompleteStatus` | Final batch consumed | Break out of program loop. |
| `0x6F` | `programStepStatus` | After each draw command executes | Used to drive the progress bar (`driver.go:282-295`); the controller throttles updates to every 10th step or the last step. |
Note `0x00` is **both** the init-success status and the `lineCmd` opcode.
The driver disambiguates by remembering which phase it's in
(`stateExecuting` vs everything else, `sim.go:148-157`).
### Connection lifecycle (one full engrave job)
The order is documented by `mjolnir.Engrave(...)` in `driver.go:111-366`:
1. `cancel()``wr(initCmd)`, loop on status until `initializedStatus`.
2. `setSpeeds(300, 300, 0xE6)` — warmup speeds.
3. `setDelays(0x14, 0x14)`.
4. `origin()` — reset the origin (engraver assumes current physical
position is (0,0)). Required because the engraver does not retain
absolute position across power cycles (`driver.go:322-327`).
5. **Needle-warmup pass**: a tiny 3-step program that walks
`(0,0) → line → move → line → move → line → move` out to (10 mm, 10 mm)
in 3 segments, to "exercise the needle" — "some machine needles are
stuck for the first few engravings" (`driver.go:325-345`).
6. `origin()` again (resets origin to back to (0,0) physically).
7. `setSpeeds(printSpeed, moveSpeed, 0xE6)` — user speeds.
8. `runProgram(prog, progress)` — the actual engrave: stream nbatches × 80
draw commands, padded with 0xFF.
9. `setSpeeds(300, 300, 0xE6)` again.
10. `moveTo(prog.End)` — park the head at the user-supplied end point.
The `MoveSpeed` / `PrintSpeed` fields on `Program` are normalised in
`[0,1]` where 0 = lowest, 1 = highest, and mapped to engraver units by
`speed = printSpeed*30 + (1-printSpeed)*1000` (`driver.go:347-358`). So
on the wire the engraver wants **smaller numbers = faster** (30 fast, 1000
slow). Defaults: `defaultMoveSpeed = 0.5`, `defaultPrintSpeed = 0.1`
(`driver.go:40-42`).
## Plate geometry
### Coordinate system
- **Units on the wire: machine steps.** One machine step = `0.00796 mm`
(`mjolnir.Step` in `driver.go:30-35`). The inverse `Millimeter = 1/Step
≈ 125.628 steps/mm` is the scale passed into `backup.Engrave`
(`gui/gui.go:1012`).
- **Origin: physical needle position at the moment `moveToOriginCmd`
fires.** The engraver has no absolute encoder; the user is expected to
jog the head to the plate's bottom-left fiducial before engraving (this
is the "EngraveSideA" GUI step in `gui.go:1029-1036`).
- **Axes orientation**: X right, Y up in the controller's image-space.
Coordinates are 24-bit unsigned on the wire → effectively only the
positive quadrant is addressable, which matches a plate fixed at the
origin.
- **StrokeWidth**: `0.3 mm` (`mjolnir.StrokeWidth`,
`driver.go:30-31`). This is the punch-impression width assumed for
hatching and font stroking — not a wire parameter, but it propagates
into the rasterisation done in `engrave.go` and so determines how many
Move/Line commands a glyph produces.
### Plate sizes (`backup/backup.go:23-57`)
Three SKUs, all expressed in **millimetres of usable engrave area** (the
metal plate is larger; these are the rectangles the engraver paints in).
| `PlateSize` | mm (W × H) | Offset on bed (mm) | Use |
|---|---|---|---|
| `SmallPlate` (0) | 85 × 55 | (97, 0) | 12-word seed only |
| `SquarePlate` (1) | 85 × 85 | (97, 49) | Seed + small descriptors |
| `LargePlate` (2) | 85 × 134 | (97, 0) | Full multisig descriptor backup |
Note the constant X-offset of **97 mm** in `offset()`
(`backup.go:49-56`). The engrave area starts 97 mm in from the origin on
every plate — that's the gap from the engraver's physical home to the
plate clamp. Y offset is 49 mm only for the square plate (the square
plate sits higher in the clamp).
### Safety margins
- `outerMargin = 3 mm` (`backup.go:79`) — minimum distance from any
drawn pixel to the plate edge.
- `innerMargin = 10 mm` (`backup.go:80`) — clear region around the
plate's mounting holes.
### Font / glyph format
The Pi-side controller **rasterises everything to Move/Line itself**.
The engraver knows nothing about glyphs, characters, or curves — it only
sees pen-up/pen-down moves to 24-bit coordinates.
From `font/font.go:11-78`:
- `font.Face` carries `Metrics{Ascent, Height float32}` plus an ASCII-only
glyph index: `Index [unicode.MaxASCII]Glyph` — **only ASCII < 0x80 is
supported**; non-ASCII runes return `false` from `Decode`.
- Each `Glyph` references a slice of `Segments []uint32` containing one
of four opcodes followed by float32-bit-encoded coord pairs:
- `SegmentOpMoveTo` (0) — 1 point
- `SegmentOpLineTo` (1) — 1 point
- `SegmentOpQuadTo` (2) — 2 points (control, endpoint)
- `SegmentOpCubeTo` (3) — 3 points (two controls, endpoint)
- Three font faces are baked in at build time, all OpenType converted
ahead of time by `font/convert.go`:
- `font/comfortaa/`
- `font/poppins/`
- `font/constant/` (used by `engrave.ConstantStringer` for the
constant-time seed/passphrase paths — see `engrave.go:610-790`).
- Quad and cubic Béziers are flattened to line segments inside
`engrave.go` (search `SegmentOpQuadTo` / `SegmentOpCubeTo`
`engrave.go:1049-1057`) before being emitted as `p.Line(...)`.
So the wire never carries a glyph index. A composer that wants to emit
the same plates must either (a) ship its own font rasteriser producing
identical Move/Line streams, or (b) reuse the upstream `font` + `engrave`
packages directly.
## How v1 differs from v2 / SH2E (high-level)
v2 ("SH2E") is the wire format in `Gangleri42/seedhammer` on the
`seedhammer-features` branch under `engrave/wire/wire.go`. It is a
**different layer entirely**: a payload format, not a live machine
protocol.
| Aspect | v1 (this doc) | SH2E |
|---|---|---|
| What it describes | Live USB-serial protocol between Pi and engraver | A self-describing payload (text grid or curve set) carried out-of-band |
| Transport | USB-serial @ 115200 baud, command/response | NFC tag / NDEF record, MIME `application/vnd.seedhammer.engrave` |
| Frame | None — raw opcodes; phase implicit | 16-byte envelope: `'SH2E'` magic + version + mode + ptype + reserved + body length + CRC-32 |
| Integrity | None | CRC-32/IEEE over body |
| Versioning | None on the wire; firmware version baked into Pi controller | Version byte (currently `0x01`) |
| Coordinates | 24-bit LE per axis, in machine steps (1 step = 7.96 μm) | Knot streams (curves) or text-grid rows; abstract — engraver decides geometry |
| Content addressed | Move/Line raster only; controller has already chosen layout | Either `PtypeTextGrid` (16 lines × 26 chars max) or `PtypeCurves` (knots) |
| State | Stateful: init → setSpeed → setDelays → origin → warmup → program → end | Stateless: payload is fully self-describing |
| Cancellation | `0xAF` byte on serial | n/a — payload is delivered atomically |
| Constant-time modes | Done at the rasteriser layer (`engrave.ConstantStringer`, `engrave.ConstantQR`) — the engraver sees identical command counts for any seed | Explicit `ModeCT = 1` flag in envelope (and v1 firmware rejects it — see "v1 firmware rejected by SH2E" note in the wire summary) |
| Suitability for the composer | Composer must rasterise text/QR into Move/Line streams itself, then either drive the engraver directly OR emit a `.shp` file that mimics what `cmd/controller` would send | Composer can produce a high-level payload and let the engraver handle layout |
Practical implication for the v1 composer web app: we cannot just emit
"the v1 wire format" the way SH2E lets you emit an envelope. We need a
**rasteriser + plate layout engine** that produces a Move/Line stream
identical to what `backup.Engrave(...)` would produce in the Pi
controller. The Move/Line stream itself is then trivially serialisable
(it's just `(opcode, x, y)` triples). Easiest paths:
1. Port `engrave/engrave.go` + `font/font.go` + `backup/backup.go` to
TS/Rust/whatever — they're pure functions, no hardware dependencies.
2. Or run the upstream Go packages headless from the composer backend
(`engrave.Program` is an interface; supply a no-op `Move`/`Line` impl
that just records calls).
## Open questions / things to verify on real hardware
1. **What is the third `setSpeedCmd` argument** (`xxx = 0xE6`)? The
driver always passes 230, never anything else. Could be Z-axis speed,
acceleration ramp, or a vestigial dwell time. Not documented anywhere
in the code; needs a logic-analyser capture.
2. **Is Z (bytes 7-9 of a draw command) really always zero?** The
simulator never reads it. If the MarkingWay firmware actually does
anything with Z, we'd be silently ignoring it. Sniff the wire on a
genuine v1 unit to confirm.
3. **`0x16` query-position**: the driver has the code but the result is
thrown away (`_, _ = atleast, queryPos` at `driver.go:221`). Does the
engraver actually respond to it? If yes, a composer could surface
live position feedback.
4. **What happens if we send draw commands with `Z != 0`?** Could be
useful for a deeper punch, or could panic the firmware. Don't try on
anything that holds value until verified.
5. **Buffer depth.** `progBatchSize = 80` is fixed in the driver. Is 80
a firmware-dictated maximum, or just a conservative number the
controller chose? If we can raise it, big plates engrave with fewer
round-trips.
6. **Plate origin assumption.** The driver assumes the user has jogged
the needle to the plate's bottom-left before pressing engrave (the
first `origin()` zeros the position there). The composer has no way
to enforce this. Consider whether we add an explicit "home / jog"
step in the composer-driven flow, or whether we keep the manual jog
step from the SeedSigner-style UI.
7. **Cancellation race.** The driver sends `cancelCmd` then waits for
`cancelledStatus`, but if a batch is mid-flight, can the engraver
drop part of a 10-byte command and resync? Worth checking what the
real firmware does — the sim assumes clean cancellation
(`sim.go:147-148`).
8. **ASCII-only glyphs.** `font.Face.Index` is sized to
`unicode.MaxASCII`. If the composer needs to emit BIP-39 in any
language other than English, the font subsystem needs widening (or
we restrict to English wordlists). Confirm whether v1 firmware has
any opinion here — probably not, since it only sees Move/Line.

20
engrave/doc.go Normal file
View File

@ -0,0 +1,20 @@
// Package engrave converts plate designs into MarkingWay engraver commands.
//
// Wraps three concerns:
//
// - Geometry: take a layout (text + SVG paths + plate type) and produce
// a stream of MoveTo/LineTo commands in the engraver's coordinate
// system (machine steps, 1 step ≈ 0.00796 mm).
//
// - Tessellation: convert higher-order curves (Quad/Cube/Spline) into
// line segments at the engraver's resolution. Uses bezier/ + bspline/.
//
// - Wire: serialise the command stream into the 10-byte binary frames
// the MarkingWay protocol expects. See engrave/wire/ for the on-the-wire
// formats (engraver USB-serial as well as SH1E for QR transport).
//
// Status: STUB — to be lifted from upstream seedhammer/seedhammer at v1.3.0
// (commit 2f071c1d8f23eb7fd39b15fc0acb8874113f801e), specifically the
// engrave package. See docs/architecture/v1-engrave-spec.md for the
// audit of the v1 wire protocol that this package targets.
package engrave

19
engrave/wire/doc.go Normal file
View File

@ -0,0 +1,19 @@
// Package wire is the parent for v1 wire formats.
//
// Two sub-protocols share this directory:
//
// - wire/ (this dir, future): the live MarkingWay USB-serial protocol
// spoken by the Pi controller to the physical engraver. 10-byte binary
// command frames in 80-command batches, with status-byte handshake. See
// docs/architecture/v1-engrave-spec.md for the full audit.
//
// - wire/sh1e/: the QR-transport envelope (magic + version + length +
// CRC32 + CBOR-encoded design). See docs/architecture/sh1e-spec.md.
//
// SH1E is a transport from composer to Pi; the live wire is the protocol
// the Pi speaks to the engraver. They are NOT the same and don't share an
// encoder.
//
// Status: STUB — live wire encoder to be lifted from upstream's mjolnir/
// driver at v1.3.0.
package wire

27
engrave/wire/sh1e/doc.go Normal file
View File

@ -0,0 +1,27 @@
// Package sh1e implements the v1 SH1E plate-design envelope.
//
// SH1E ships a plate design (plate type + text blocks + optional SVG paths)
// from the browser-side composer to a SeedHammer v1 Pi controller, intended
// primarily for transport via QR code(s) scanned by the Pi's camera.
//
// The envelope structure:
//
// +----------+----------+--------------+--------+-----------+
// | magic | version | payload_len | crc32 | payload |
// | 4 bytes | 1 byte | 2 bytes (LE) | 4 bytes| N bytes |
// | "SH1E" | 0x01 | uint16 | | CBOR |
// +----------+----------+--------------+--------+-----------+
//
// Payload is CBOR-encoded per RFC 8949 deterministic encoding rules so a
// given Design produces byte-identical bytes every time.
//
// The composer side encodes; the Pi controller side decodes + validates +
// rasterises locally using the trusted engrave/ pipeline. The composer
// never ships pre-rendered engraver commands — keeping the security
// boundary on the Pi.
//
// Full spec: docs/architecture/sh1e-spec.md
//
// Status: STUB — reference encoder + decoder + canonical-encoding test
// fixtures pending. Will land in Phase 1 of the project.
package sh1e

6
font/comfortaa/doc.go Normal file
View File

@ -0,0 +1,6 @@
// Package comfortaa holds the Comfortaa pre-rasterised font data for the
// v1 engrave pipeline.
//
// ASCII-only. To be lifted verbatim from upstream seedhammer/seedhammer at
// v1.3.0. Status: STUB.
package comfortaa

5
font/constant/doc.go Normal file
View File

@ -0,0 +1,5 @@
// Package constant holds the constant-width fallback font for the v1
// engrave pipeline.
//
// ASCII-only. To be lifted verbatim from upstream at v1.3.0. Status: STUB.
package constant

21
font/doc.go Normal file
View File

@ -0,0 +1,21 @@
// Package font provides the v1 controller's pre-rasterised engraver fonts.
//
// The engraver itself knows nothing about glyphs — the Pi controller
// rasterises every character to MoveTo/LineTo segments before sending bytes
// down the USB-serial pipe. These fonts (Comfortaa, Poppins, and a
// constant-width fallback) are pre-baked OpenType segments compiled into
// the Go binary.
//
// Subdirectories carry the actual glyph data:
//
// - font/comfortaa/ — Comfortaa font, pre-rasterised
// - font/poppins/ — Poppins font, pre-rasterised
// - font/constant/ — constant-width fallback font
//
// Character set is ASCII-only. Lifting non-ASCII glyphs requires either
// new fonts upstream or a deliberate decision to add Unicode support to
// the v1 engrave pipeline (out of scope for this companion port).
//
// Status: STUB — directory tree mirrors upstream layout; glyph data to be
// lifted verbatim from upstream seedhammer/seedhammer at v1.3.0.
package font

5
font/poppins/doc.go Normal file
View File

@ -0,0 +1,5 @@
// Package poppins holds the Poppins pre-rasterised font data for the v1
// engrave pipeline.
//
// ASCII-only. To be lifted verbatim from upstream at v1.3.0. Status: STUB.
package poppins

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/mineracks/seedhammer-v1-companion
go 1.22

22
gui/doc.go Normal file
View File

@ -0,0 +1,22 @@
// Package gui is the v1 controller's on-device UI — screen drawing, menu
// navigation, page state machine.
//
// Self-contained, hardware-abstracted: it talks to platform/ for input
// events and frame output. That abstraction lets the same gui run on a
// real Pi (via driver/wshat + driver/drm) and in the browser emulator
// (via platform/v1 + canvas).
//
// Screen flow follows the v1 controller's existing conventions:
//
// - Joystick (Up/Down/Left/Right/Center) = navigation
// - Button1 = back
// - Button2 = secondary action (hold-to-arm dry-run)
// - Button3 = primary confirm (hold-to-engrave)
//
// Hold-to-confirm distinguishes Pressed from Click with a confirmDelay
// timer (see gui.go in upstream).
//
// Status: STUB — to be lifted from upstream seedhammer/seedhammer at v1.3.0.
// Browser-emulator-specific tweaks (mock camera, keyboard event source)
// live in platform/v1, NOT in this package.
package gui

25
input/doc.go Normal file
View File

@ -0,0 +1,25 @@
// Package input is the v1 controller's hardware input abstraction.
//
// On a real Pi: reads GPIO pins from the WaveShare 1.3" LCD HAT via
// periph.io (driver/wshat in upstream layout).
//
// In the browser emulator: keyboard event source from the host page,
// translated to the same Event{Button, Pressed} envelope.
//
// Eight inputs total: 5-way joystick (Up/Down/Left/Right/Center) + 3 keys
// (Button1/Button2/Button3). GPIO pin map (BCM283x):
//
// Up → GPIO 6
// Down → GPIO 19
// Left → GPIO 5
// Right → GPIO 26
// Center → GPIO 13
// Button1 → GPIO 21
// Button2 → GPIO 20
// Button3 → GPIO 16
//
// Active-low with internal pull-ups, 10 ms debounce.
//
// Status: STUB — to be lifted from upstream at v1.3.0 (driver/wshat path,
// formerly input/input.go pre the v1.2.0 refactor).
package input

11
internal/golden/doc.go Normal file
View File

@ -0,0 +1,11 @@
// Package golden is a test-fixture provider used by both the composer-side
// preview WASM and the emulator to assert that rendered output is identical
// to reference goldens.
//
// Golden images, golden SH1E byte sequences, and golden engrave command
// streams all live here. CI compares live rendering against these.
//
// Status: STUB — fixtures generated progressively as features land. The
// SH1E reference encoder will be tested against the documented examples
// in docs/architecture/sh1e-spec.md.
package golden

21
platform/v1/doc.go Normal file
View File

@ -0,0 +1,21 @@
// Package v1 holds platform adapters that bind gui/, input/, and engrave/
// to either a real SeedHammer v1 device or the browser emulator.
//
// Two build-time targets:
//
// - real: GOOS=linux GOARCH=arm — for the Pi Zero v1.3 itself.
// Binds gui/ frame output to driver/drm/, input/ to
// driver/wshat/ (GPIO via periph.io), engrave/ to
// driver/mjolnir/ (USB-serial to MarkingWay).
//
// - browser: GOOS=js GOARCH=wasm — for the in-browser emulator.
// Binds gui/ frame output to a JS-exposed canvas writer,
// input/ to keyboard events forwarded from the page,
// engrave/ to a null sink (preview) or animation harness
// (visual playback).
//
// Build constraints select the right adapter; both expose the same
// platform.Driver interface so gui/ doesn't know which it's running on.
//
// Status: STUB — interface design and both adapters land in Phase 1 + Phase 2.
package v1

19
web/README.md Normal file
View File

@ -0,0 +1,19 @@
# web/
Static-site assets for each browser target.
| Path | What it ships |
|---|---|
| `composer/` | The plate composer PWA (uses `cmd/composer/` WASM) |
| `emulator/` | The v1 emulator PWA (uses `cmd/emulator/` WASM) |
| `combined/` | The three-pane combined sim (uses all three) |
| `seedsigner-sim/` | The Pyodide-hosted SeedSigner emulator |
| `shared/` | Common CSS/JS/assets used by multiple shells |
Each subdirectory has its own `index.html`, `app.js`, `app.css`,
`manifest.webmanifest`, `sw.js`, modelled on Gangleri42's PWA shells.
Build pipeline (TBD — likely Vite or a small `make` rule that wires
`go build -o app.wasm ./cmd/X` and copies static files to `dist/`).
Status: skeleton only; shells lifted in Phase 1.