
Ursaminor pcb
markstory/ursa-minor-keyboard
Design and schematics for the ursaminor keyboard

Open Source Split wireless columnar 44-key keyboard with a focus on sound!
Hojicha44-Left is an open-source printed circuit board by BitPeppr, from the BitPeppr/Hojicha44 repository on GitHub. It is a 2-layer board of 150.5 × 114.6 mm with 54 parts on 47 nets, routed with 384 tracks and 71 vias.
It is built around the K08, 1N4148W and K12, with the K13, K14 and K15 among its other parts. It belongs with the keyboards and input boards and the nRF52 and nRF5x boards of this library.
Custom split columnar mechanical keyboard (44 keys + 2 encoders, nice!nano x2).
Roadmap: KEYBOARD_PROJECT_ROADMAP.md.
Current phase: PCB release v0.5.0 cut + fab-pointable; plate rev-8 gated
(+ new z-stack gate); case/cover v4.1 generated + gated (thin top plate,
key-field ledge, floor −6.35) with committed STEP solids; ZMK CI green.
Board release evidence: generated/release/v0.5.0; order-time print
geometry: generated/print/v0.5.0/MANIFEST.txt (STEP files verified in CI);
renders: generated/renders/v0.5.0. Remaining before any batch: the
first-article checklist (spec/first-article.md) — coupon-first, per its
order policy. Layout geometry lives in
layout/ergogen/config.yaml (frozen snapshot: layout/- FINAL/keyboard_layout_v7);
each half has a placed+routed board file — see Schematic.
| Path | Tracked? | What it is |
|---|---|---|
boards/shields/hojicha44/ | yes | The ZMK shield definition (devicetree hojicha44.dtsi, left/right overlays, Kconfig) — standard ZMK module layout; consumed by the firmware build via zephyr/module.yml |
config/ | yes | ZMK build inputs — hojicha44.keymap (+ 4 variant keymaps), *.conf Kconfig, west.yml. The keymaps are generated by config/gen_keymap.py from the spec (byte-compared by CI every run) |
spec/ | yes | Engineering source of truth (human-authored): layout.yaml, electronics.yaml, case.md, bom.md, first-article.md, open items. Generators read this; config/ is its firmware-facing output — same pipeline, different roles: spec = source, config = consumed by west/Zephyr |
zephyr/module.yml | yes | Declares this repo as a Zephyr module (makes boards/ + config/ visible to west) |
zmk/gpio-map.md | yes | Machine-generated pin-map doc (cross-checked against the dtsi) |
build.yaml | yes | CI firmware matrix consumed by .github/workflows/build.yml (one ZMK build per shield, pinned ZMK v0.3) |
pcb/ | yes | KiCad project + generators (gen_schematic.py, gen_pcb.py, gen_route.py) + the committed .net netlists CI parses (no KiCad in CI) + vendored stock |
plate/, case/, layout/ | yes | Generators + tracked outputs (plate DXF/SVG/lands; case scripts; layout points). case/.venv/ and case/out/ are local-only |
generated/ | yes, selective | Derived artifacts worth versioning: release/v0.5.0 (the shipped gerbers/reports bundle), renders/v0.5.0 (final PNGs), layout points, print manifest. The regenerable mesh tree generated/renders/assets/ is ignored |
tests/ | yes | spec_checks.cjs — the static gate suite (npm test) |
renders/ | yes | Phase-10 render pipeline scripts (see renders/README.md) |
node_modules/ | no | npm deps for the gate suite — recreated with npm ci; pinned by package-lock.json |
out/ | no | Local build outputs: out/firmware (west/Zephyr build destination — the build result stays out of git; the CI rebuilds it), out/fit (fit prints) |
opencode/ | no | Agent-harness scratch: run logs, probe scripts, and gen_pcb's pre-regen board archive (rejected/pre-regen-* snapshots cited from notes and hand-copper.txt) |
| What | Where | Notes |
|---|---|---|
| Parametric source of truth | layout/ergogen/config.yaml | Ergogen 4.2.1, 46 pts: 6×3 + 4 thumbs + encoder/side |
| KLE sketch (derived) | layout/- FINAL/keyboard_layout_v2.json | Do not hand-edit; regenerate |
| KLE sketch (archive) | layout/- FINAL/keyboard_layout_v1.json | Original row-staggered sketch |
| Layout decisions | spec/layout.yaml | Keymap, thumb roles, relocated keys |
| Board outline | layout/generated/v2/outlines/board.dxf | PCB edge, no encoder hole — the EC11E needs copper under it |
| Plate outline | plate/Hojicha44-{Left,Right}-plate.dxf | Same contour + tabs, 16.2 × 12.4mm chamfered encoder window |
| Generators | layout/generators/ | export_kle.py (points → KLE), render_preview.mjs (KLE → labeled PNG) |
| Ergogen outputs | layout/generated/v2/ | Points, outlines, demo SVGs, keymap render |
| Snapshots | layout/- FINAL/ | v1–v4 KLE, v5–v6 Ergogen, v7 config, v8 layout (frozen) |
| What | Where | Notes |
|---|---|---|
| Electronics spec | spec/electronics.yaml | Source of truth: matrix, pin map, parts |
| Generator | pcb/generators/gen_schematic.py | Spec → .kicad_sch + ERC + net audit; never hand-edit the schematic |
| Left project | pcb/kicad/Hojicha44-Left.kicad_sch | One project + one board file per half (a split is two PCBs) |
| Right project | pcb/kicad/Hojicha44-Right.kicad_sch | Same; both share the vendored libs below |
| Board files | pcb/kicad/Hojicha44-{Left,Right}.kicad_pcb | Placed+routed: 54 footprints / 48 nets per half, outline from board.dxf. Hand copper is preserved via hand-copper.txt; router-owned copper regenerates on every run |
| Hand-copper manifest | pcb/kicad/hand-copper.txt | The authoritative preserve set, scoped per half ([Left]/[Right]). A regen keeps only items matching manifest signatures; a manifest line the committed board lacks is a hard error |
| Placement generator | pcb/generators/gen_pcb.py | Points + outline + netlist → boards. Extraction reads the committed baseline (git HEAD), gates run before the write, and a DRC-rejected board is reverted so it can never leak into the next build |
| ZMK firmware | boards/shields/hojicha44/ + config/ | Shield dtsi/overlays/Kconfig + keymap/conf; compiled by CI (.github/workflows/build.yml), no local west build |
| ZMK evidence | zmk/gpio-map.md | Machine-generated GPIO continuity map |
| Project symbols | pcb/kicad/Hojicha44.kicad_sym | Local nice!nano (stock libs cover the rest) |
| Project footprints | pcb/kicad/Hojicha44.pretty/ | Choc hotswap, PJ-320A, 2x12 nice!nano socket (vendored, see spec sources) |
Why two projects: a single flat sheet produces one netlist, so "Update PCB from schematic" would drop both halves into one board file. One project per half keeps each outline, netlist and DRC separate; the half-to-half link (TRRS_T/TRRS_R1 crossover) lives in the spec, not the netlist.
Each half differs from the other only in the TRRS TX/RX swap: D1→TRRS_T
on the left, D1→TRRS_R1 on the right, for a straight cable. The crossover
is copper, so both halves share identical UART pinctrl (TX=P0.06 RX=P0.08)
— no firmware swap; see zmk/.
python3 pcb/generators/gen_schematic.py # spec check + both sheets + audit + ERCpython3 pcb/generators/gen_pcb.py # place both boards + containment + DRC gateThe schematic generator also asserts the two things ERC and DRC cannot catch:
Socket_Strips_2x12_P2.54mm_NiceNano numbers 1–12
down the TX/D1 edge and 13–24 down the RAW edge in the opposite
direction, so pads 1 (D1) and 24 (RAW) share the USB end. The stock
PinSocket_2x12 is row-major with 2.54mm row spacing and would land every MCU net on the wrong
pad while still passing both checks — and the module would not fit.kicad-cli lives inside the KiCad bundle
(/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli, v10.0.6 here) —
it is not on PATH by default. The generator finds it automatically.
TRRS cable — buy a 90° right-angle one. The two jack mouths face each other across 22.40mm of mouth-to-mouth clearance (plastic faces; 20.42mm board-edge to board-edge, each mouth 0.99mm recessed behind its edge — silk tops at 21.40mm, still recessed). A molded straight plug protrudes ~12mm past the jack face, so the pair needs ~24mm and is a tight fit at best. A 90° plug protrudes only ~2–4mm axially because the boot exits perpendicular, so the current gap is ample. 90° is the community standard for splits anyway (less port torque). Size the cable port-to-port +2–5cm minimum — both cables exit upward and arch over the gap.
Always power-cycle in this order: plug/unplug the TRRS with both halves switched OFF and USB unplugged from both, then switch on. Switch OFF alone is insufficient — USB still powers the controller. Sleeve-GND mates at an angle during insertion, so connecting live can glitch TX/RX.
Don't treat TRRS as a daily link. splitkb's own nice!nano FAQ is blunt: "Do not use a cable between the halves… shorting connections when unplugging while the battery is still on… unbalanced charging when two batteries are connected." This design already isolates ring 2, which blocks the charging half; the shorting half is only mitigated by the power-off ordering above. TRRS here is a bring-up/debug fallback — move to 4-pin JST if you want it in normal use.
Battery: the 301230 goes directly under the module. This is correct. The cell's south 5mm sits over the antenna keepout circle, which looks alarming on the preview but is exactly what Lily58, Corne, Sofle, the whole splitkb Aurora series, and nicekeyboards' own docs do — none apply a cell keepout. The r=5 copper keepout is on our PCB and is honoured independently; the cell is a different layer. A PCB trace antenna has a null perpendicular to its plane, so a cell 3mm below the antenna end is in the null, not shadowing the main lobe. If BLE range ever disappoints, the documented upgrades are a 301228 (90mAh) or a 306080 between PCB and bottom plate with 9mm standoffs.
npx ergogen layout/ergogen -o layout/generated/v2 --svg --clean
node layout/generators/dump_points.cjs # restores the JSON/YAML sidecars (4.2.1 no longer writes them)
python3 layout/generators/export_kle.py --check
node layout/generators/render_preview.mjsPCB (place + route; generator copper + preserved GUI copper):
python3 pcb/generators/gen_schematic.py # spec check + both sheets + guards + audit + ERC
python3 pcb/generators/gen_pcb.py # place + route + containment + copper checks + DRCgen_pcb.py owns the outline, footprints, nets, placement and generator
copper (deterministic router gen_route.py, revision-tagged so stale copper
is dropped, never silently kept). GUI copper is the
other way round: (segment …), (via …) and (zone …) are preserved verbatim
from the board file (net codes remapped by name, since codes are not stable
across netlist exports). GUI routes survive regeneration (generator copper is
revision-tagged, so a router rev drops only stale generator copper, never
GUI copper). The build fails, rather than quietly producing a
broken board, if preserved copper no longer lands on a pad/via/track — the usual
result of moving a footprint without re-routing — or if it enters the r=5mm
antenna keepout.
layout/- FINAL/keyboard_layout_v2.json into keyboard-layout-editor.layout/ergogen/config.yaml into ergogen.xyz.layout/generated/v2/keymap.png (+.svg) is a labeled render with the
keymap baked in — no Ergogen PNG export exists, so this script draws it
from the KLE (ImageMagick required).| Qty | Value | Footprint | References |
|---|---|---|---|
| 22 | 1N4148W | Diode_SMD:D_SOD-123 | D_L00, D_L01, D_L02, D_L03, D_L04, D_L05, D_L06, D_L07, D_L08, D_L09, D_L10, D_L11, D_L12, D_L13, D_L14, D_L15 +6 |
| 3 | FIDUCIAL | Fiducial:Fiducial_1mm_Mask3mm | FID_L1, FID_L2, FID_L3 |
| 2 | TOOLING | MountingHole:MountingHole_3.2mm_M3 | MT_L1, MT_L2 |
| 1 | BATT_LIPO | Connector_JST:JST_PH_S2B-PH-K_1x02_P2.00mm_Horizontal | J_L2 |
| 1 | EC11E-Switch | Rotary_Encoder:RotaryEncoder_Alps_EC11E-Switch_Vertical_H20mm_CircularMountingHoles | ENC_L1 |
| 1 | K00 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L00 |
| 1 | K01 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L01 |
| 1 | K02 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L02 |
| 1 | K03 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L03 |
| 1 | K04 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L04 |
| 1 | K05 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L05 |
| 1 | K06 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L06 |
| 1 | K07 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L07 |
| 1 | K08 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L08 |
| 1 | K09 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L09 |
| 1 | K10 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L10 |
| 1 | K11 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L11 |
| 1 | K12 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L12 |
| 1 | K13 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L13 |
| 1 | K14 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L14 |
| 1 | K15 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L15 |
| 1 | K16 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L16 |
| 1 | K17 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L17 |
| 1 | K18 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L18 |
| 1 | K19 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L19 |
| 1 | K20 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L20 |
| 1 | K21 | Hojicha44:SW_Hotswap_Kailh_Choc_V1_1.00u | SW_L21 |
| 1 | PJ-320A TRRS | Hojicha44:TRRS-PJ-320A | J_L1 |
| 1 | PWR_SW | Button_Switch_THT:SW_Slide-03_Wuerth-WS-SLTV_10x2.5x6.4_P2.54mm | SW_LPWR1 |
| 1 | nice!nano v2 | Hojicha44:Socket_Strips_2x12_P2.54mm_NiceNano | U_L1 |
It measures 150.5 × 114.6 mm and has 2 copper layers, 1.6 mm thick.
54 parts, built around the K08, 1N4148W, K12 and K13. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
From the BitPeppr/Hojicha44 repository on GitHub, at the commit this page was made from: the board is pcb/kicad/Hojicha44-Left.kicad_pcb and its schematic pcb/kicad/Hojicha44-Left.kicad_sch. PCBKit opens the board in the browser, with nothing to install.
Its repository states no licence, so its author keeps all rights: ask BitPeppr before reusing the design.

markstory/ursa-minor-keyboard
Design and schematics for the ursaminor keyboard




reivax-boucoi/PCB_Kicad
Repo for all my home projects involving PCBs developped with Kicad


rotep/scissorball
Split 36-key low-profile keyboard with Kailh PG1425 scissor switches and a 25mm PMW3360 trackball. KiCad / FreeCAD (scripted) / QMK.
