Skip to content

universal-tester-product

Turning the UT002 bench station into a sellable product: an installable station app, fixtures that are data instead of programs, and a price. Operating notes for running the existing stations stay in universal-tester; board layout is universal-tester-003. Positioning and pricing context is competitor-intel.

Anchor date: PCB Carolina, 11 Nov 2026. Nine weeks from 2026-09-08.

Plans

  • Research and platform decision: https://claude.ai/code/artifact/b556e8ec-1033-417f-b73b-304aa0a2f690
  • Nine-week build order: https://claude.ai/code/artifact/79986cde-6910-480f-bf34-eeec51e1a59f

Milestones

  • A (wks 1-2) Package: installable, signed station app. Branch product/milestone-a.
  • B (wk 3) Operator screen, ISA-101, split from the bench control panel.
  • C (wks 4-7) Fixtures as data. This is the product.
  • D (wk 8) OpenHTF-shaped export. No dashboard.

Findings that should not be relitigated

The browser cannot drive the instruments. Web Serial and WebUSB are Chrome and Edge only, absent from Safari and stable Firefox, absent from iOS. Windows WebUSB needs a Zadig driver swap that breaks the existing VISA setup. The station machine runs native Python, permanently.

The web-vs-desktop-vs-mobile question is already answered by examples/universal-tester-control-panel.py: one stdlib HTTP server on 127.0.0.1:8765 is simultaneously a browser tab, a pywebview kiosk, and a LAN tablet view. One HTML file, three deliveries, no second language. Phone is read-only by physics, not by choice.

Do not build a test executive. openhtf is Apache-2.0, 720 stars (not the thousands often claimed), human commits through 2026-09-01 including an Angular 4 to 16 web-GUI modernization. Copy its phase and measurement shape; do not migrate onto it. Note openhtf.org and openhtf.com are TofuPilot-run, not Google.

Do not build cloud analytics. TofuPilot (~$50/user/mo) and WATS own that layer. Export their schema instead.

The real blocker is that fixtures are programs, not data. Cow Robot is 4,574 lines and Cow Remote 2,587. Until a fixture is a declarative test plan, this is consulting with a box attached. Scope is deliberately narrow: sequence and limits move to TOML behind a step registry, hardware manipulation stays Python, because TestStation.require_between is already the right measurement model.

Standards that gate deals: ISA-101 (operator screens) and IPC-1782 (traceability). The append-only JSONL already covers most of IPC-1782.

Stay in the one repo. The split instinct is backwards: host-api/ was 605 MB of the tracked bytes, more than all the KiCad. The coupling is load-bearing (four versions recorded per result), and git subtree split keeps the exit cheap. See semver-discipline.

One vault, not two. The cross-links to competitor-intel and decisions/embeddedera-positioning are what determine this product's niche and price. A second vault severs exactly those.

Deferred on purpose

Licence enforcement (until someone copies it), auto-update (past ~10 field stations), phone app (a customer with multiple stations asks), Tauri or Electron (pywebview proves inadequate), multi-language UI (a non-US customer signs).

Log

  • 2026-09-08: Productization research and nine-week build plan written. Findings above. Milestone A started.
  • 2026-09-08: Untracked 3,160 LED camera capture frames (~559 MB, the large majority of tracked bytes). Gotcha found while doing it: production reads two files out of those capture directories and hard-fails without them, the registration reference in examples/camera-closer-setup/ and robot-led-camera-tests/20260808T195803Z/result.json. The ignore rule matches frames only. History still carries the blobs, so a fresh clone is ~623 MB until a rewrite is decided separately.
  • 2026-09-08: A0 packaging spike: backends pass frozen, bench half unverified. host-api/packaging/spike_probe.py + .spec + rthook_libusb.py, branch product/milestone-a. Four gotchas, all worth remembering: (1) libusb architecture trap — this Mac has x86_64 libusb at /usr/local/lib (Intel Homebrew) and arm64 at /opt/homebrew/lib. ctypes.util.find_library returns the x86_64 one first and arm64 Python cannot load it. The .sh launchers already fix this with DYLD_LIBRARY_PATH, but a double-clicked app has no shell env, so the bundle ships the right dylib and a PyInstaller runtime hook primes pyusb's backend cache before pyvisa-py asks for a default. (2) PlatformIO's Python cannot be frozen — it is built without a shared library and it is the base of .cow-test-venv. Builds need a separate .build-venv on the Homebrew framework Python (3.14). Running from source is unaffected, but the production interpreter can never produce an installer. (3) pyproject had no package discovery config — current setuptools refuses to guess on the flat layout and pip install -e . failed outright. Fixed, plus the package-data entry the operator UI needs in A1. (4) Editable installs are invisible to PyInstaller (it cannot follow the .pth hook), so the spec sets pathex to the source tree. Bundle is 157 MB, 118 MB of it OpenCV — whether camera inspection ships is the A3 size decision. Still to do: rerun the probe with the SPD, SDM and UT002 connected. Until then only the packaging half of A0 is answered.

  • 2026-09-08: A1 done. Control panel moved into the package: universal_tester/app/{server.py,control_panel.py,static/}. The HTTP layer already spoke to its state through five members (snapshot, execute, event, closed), so it lifted out generic and milestone B's operator screen can reuse it as a second page. ControlState is byte-identical apart from three lines: shutdown_server moved from the handler into _execute_once(), which already holds the same RLock, removing the handler's knowledge of state.lock and disconnect_all(). examples/universal-tester-control-panel.py is now a four-line shim so all four launchers keep working untouched. PANEL_VERSION 1.0.4 (AGENTS.md: refactor is PATCH). Gotcha: collect_submodules() brings code but not data — the spec also needs collect_data_files("universal_tester") or a frozen build serves a blank page instead of failing. The spike now asserts the assets are present and confirms it frozen. Suite: 228 passed, 33 subtests. Loose end noticed, not fixed: universal_tester.__version__ is undefined, so the frozen app reports "version unknown". Worth setting given the rule that every result records the software version.

  • 2026-09-08: A2 done. python -m universal_tester.app opens the panel in a pywebview window; --kiosk fullscreen for the floor, --browser falls back to a tab. pywebview 6.2.1 + pyobjc 12.2.2, Cocoa backend, works on this Mac. The design rule that mattered: the new module decides only how the page is presented. main() was split into build_parser / validate_args / build_station / shutdown_station, and both entry points share them, so there is one teardown rather than one per entry point. A closing window, Ctrl-C and DISCONNECT & EXIT all end in shutdown_station(), which stops the watchdog and forces outputs off. Two safety paths would have been the real bug here. New case the browser never had: DISCONNECT & EXIT stops the server from inside the page, which in a window leaves a live window on a dead backend. The serving thread destroys the window when serve_forever() returns. pywebview is an optional extra (pip install 'universal_tester[app]') so a test script importing the library does not pull in a windowing stack. Tests mutation-checked, not just written: deleting window.destroy() fails the window test and deleting shutdown_station() fails the teardown test. 234 passed. For A3: pywebview needs pyobjc bundled on macOS, which is the next spec problem.
  • 2026-09-08: A3 done. packaging/station.spec builds dist/UniversalTesterStation/ plus a double-clickable Universal Tester Station.app (bundle id com.embeddedera.universaltester.station, LSMultipleInstancesProhibited so a second copy cannot fight the first over the USB sessions). Shared build logic in packaging/_common.py. Camera decision, measured: excluding cv2 takes the bundle 160 MB → 51 MB. Safe because the panel has no camera control and the optical modules import cv2 lazily inside _cv2(). Reverse the exclusion at milestone C when the app runs LED inspection, and expect ~4x growth. Three things only a real run caught: (1) --browser was broken in every real invocation — it delegated to the browser entry point, which re-read sys.argv with a parser that had never heard of --browser/--kiosk. The A2 test mocked the delegation and sailed past it. Lesson: mocking the seam hides the seam. main() is now a thin wrapper over run_browser(args) and the args are handed over, not re-read. (2) __main__.py cannot be the frozen entry script — PyInstaller runs the entry script as a top-level module where relative imports have no package. Hence packaging/station_entry.py. (3) Frozen apps fully buffer stdout to a pipe, so the banner appeared only at exit. station_entry.py line-buffers both streams, guarded. Frozen cold start is ~7 s — my first two test runs "failed" only because I did not wait long enough. Worth remembering before A4 signing. Verified frozen from a stripped env: API, index.html, app.js, styles.css all
  • 235 tests pass. Bench devices still unverified.
  • 2026-09-09: Milestone B done. Operator screen mirrors the production terminal. production/events.py observer + 8 one-line emit() calls in terminal.py (no logic forked); app/operator_state.py (RLock, strictly read-only, execute() changes nothing); app/operator.py (start_operator_screen() never raises into the production program — logs and returns a no-op handle); app/static/operator.html. PanelServer gained index_name so one server class serves either page — that is what the A1 split bought us. The rule that shaped it: emitting must never stop a board test. A listener that raises is dropped and swallowed. Verified directly with an always-raising listener: terminal keeps printing, listener unsubscribes. Gotcha worth remembering: [hidden] loses to any author display rule. .action-panel{display:flex} beat the browser's [hidden], so stale operator instructions stayed on screen forever, including beside a FAIL. No unit test would ever have caught this — it needed looking at the rendered page. Fixed with an explicit [hidden]{display:none!important} and guarded by a test that reads the stylesheet. Apply this rule to every page we write. Also: one apparent second bug (test list ordering) was my own bad synthetic test data, not the code. Check the real API before believing a screenshot. Deferred on purpose: answering prompts from the screen. That means reaching into blocking stdin reads on the production path for modest gain — after C. Keyboard/scanner stays the input device; the screen is the display. Suite: 248 passed. Demo without hardware: python -m universal_tester.app.operator --demo
  • 2026-09-09: C1, C2, C4 done — the product shape exists. production/plan.py: @step("name") registry + TOML plans holding sequence, limits, units, display names, on_fail (abort/continue) and modes (PRETEST/FINAL). Step returns float (judged), bool (verdict), or None (passed unless raised); raising is a failure with the text recorded. Design rules baked in, both deliberate: (1) Validate the whole plan before any hardware is energized, reporting every problem at once — an unknown step name must never surprise a run with a board powered. (2) A plan with mode-specific steps run without a mode raises rather than silently executing a shorter run that looks like a pass. That silent-shorter-run was the worst failure mode available and is now closed. Also: a limit with no unit is rejected, because a unit-less limit is how a millivolt becomes a volt. C4 = TestPlan.provenance() → plan_id, plan_version, plan_sha256 in every result. Hash changes when a limit changes. That is most of IPC-1782 for one field. examples/cow-remote.plan.toml (11 steps, real fixture values) + cow_remote_steps.pyhonest stubs that raise NotImplementedError naming the function in remote-board-test.py that still owns the logic. Verified all 11 raise and every referenced symbol actually exists. This is the C3 porting checklist, not a fake port. Neither production program is switched over. Defect found by running it, not reading it: an aborting plan skipped 9 steps and reported all of them as FAIL — one fault printed ten red failures with no way to see what broke. Skipped is now a third outcome end to end (terminal SKIP, event flag, muted on the operator screen). The worker's test asserted the old behaviour; rewrote it to assert that a skip and a failure never read alike. Suite: 292 passed. Correction to an earlier note: production fixtures do NOT use TestStation.require_between — only siglent_power_and_measure.py does. They measure inline with limits as module constants. So plan.py defines the measurement contract rather than inheriting one.