From 4742099be8a94e4ddbdd906be99bd794ba471252 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 18 Oct 2026 17:12:30 +0200 Subject: [PATCH] Add offline Rayforge and BalorMK validation spikes --- assets/branding/generated/manifest.json | 4 +- docs/adr/0002-reuse-rayforge-and-balormk.md | 20 ++ docs/research/meerk40t-mock-mopa-spike.md | 67 +++++ docs/research/open-source-landscape.md | 5 +- docs/research/rayforge-addon-spike.md | 82 ++++++ .../rayforge-jcz-equivalence-spike.md | 64 +++++ docs/research/rayforge-jcz-negative-cases.md | 37 +++ docs/research/rayforge-jcz-packet-spike.md | 47 ++++ docs/roadmap.md | 37 ++- scripts/generate_brand_assets.py | 21 +- scripts/spike_meerk40t_mock_mopa.py | 201 ++++++++++++++ scripts/spike_rayforge_addon.py | 143 ++++++++++ scripts/spike_rayforge_jcz_equivalence.py | 152 +++++++++++ scripts/spike_rayforge_jcz_negative_cases.py | 127 +++++++++ .../pirate_ray_jcz/__init__.py | 1 + .../pirate_ray_jcz/worker.py | 245 ++++++++++++++++++ .../rayforge-addon.yaml | 12 + tests/fixtures/meerk40t-mock-mopa-trace.json | 33 +++ tests/fixtures/rayforge-addon-spike.json | 24 ++ tests/fixtures/rayforge-jcz-equivalence.json | 50 ++++ .../fixtures/rayforge-jcz-negative-cases.json | 38 +++ tests/test_meerk40t_mock_spike.py | 44 ++++ tests/test_rayforge_addon_spike.py | 91 +++++++ 23 files changed, 1535 insertions(+), 10 deletions(-) create mode 100644 docs/research/meerk40t-mock-mopa-spike.md create mode 100644 docs/research/rayforge-addon-spike.md create mode 100644 docs/research/rayforge-jcz-equivalence-spike.md create mode 100644 docs/research/rayforge-jcz-negative-cases.md create mode 100644 docs/research/rayforge-jcz-packet-spike.md create mode 100644 scripts/spike_meerk40t_mock_mopa.py create mode 100644 scripts/spike_rayforge_addon.py create mode 100644 scripts/spike_rayforge_jcz_equivalence.py create mode 100644 scripts/spike_rayforge_jcz_negative_cases.py create mode 100644 spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/__init__.py create mode 100644 spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py create mode 100644 spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml create mode 100644 tests/fixtures/meerk40t-mock-mopa-trace.json create mode 100644 tests/fixtures/rayforge-addon-spike.json create mode 100644 tests/fixtures/rayforge-jcz-equivalence.json create mode 100644 tests/fixtures/rayforge-jcz-negative-cases.json create mode 100644 tests/test_meerk40t_mock_spike.py create mode 100644 tests/test_rayforge_addon_spike.py diff --git a/assets/branding/generated/manifest.json b/assets/branding/generated/manifest.json index 4504f94..ec87abe 100644 --- a/assets/branding/generated/manifest.json +++ b/assets/branding/generated/manifest.json @@ -141,8 +141,8 @@ }, { "path": "ios/AppIcon.appiconset/Contents.json", - "sha256": "89af6a0409542706ccb6cf406a288e7ce65d4f4b3a463930674bb1569225cbd1", - "bytes": 2298 + "sha256": "3275a59b6f06b7690e1923669a19088b42930310bdc725b7004f51f36ad61bb8", + "bytes": 2182 }, { "path": "ios/AppIcon.appiconset/icon-1024.png", diff --git a/docs/adr/0002-reuse-rayforge-and-balormk.md b/docs/adr/0002-reuse-rayforge-and-balormk.md index 30a2f6a..1de30b9 100644 --- a/docs/adr/0002-reuse-rayforge-and-balormk.md +++ b/docs/adr/0002-reuse-rayforge-and-balormk.md @@ -85,3 +85,23 @@ workflow. Record evidence before choosing a standalone app. See [Open-source landscape](../research/open-source-landscape.md) for the candidate matrix, inspected commits, risks and detailed spike plan. +Subsequent offline evidence: + +- [MeerK40t mock MOPA spike](../research/meerk40t-mock-mopa-spike.md): + deterministic controller-level frequency, pulse-width and square encoding; +- [Rayforge addon spike](../research/rayforge-addon-spike.md): external binary + driver load and lifecycle are viable, with a documented registry API gap. +- [Rayforge-to-JCZ equivalence spike](../research/rayforge-jcz-equivalence-spike.md): + the required MOPA and geometry semantics survive a real Rayforge `Ops` + stream and match the pinned BalorMK mock trace. +- [JCZ negative cases](../research/rayforge-jcz-negative-cases.md): the encoder + boundary rejects profile mismatch, invalid values, out-of-field geometry and + unsupported commands without a transport. +- [JCZ 12-byte record spike](../research/rayforge-jcz-packet-spike.md): the + external encoder deterministically packs controller-shaped command records + matching the pinned BalorMK mock values, while retaining no transport. + +These results support continuing toward complete list buffering and production +integration, but they do not yet satisfy every proof obligation or change this +ADR's proposed status. + diff --git a/docs/research/meerk40t-mock-mopa-spike.md b/docs/research/meerk40t-mock-mopa-spike.md new file mode 100644 index 0000000..8bb727f --- /dev/null +++ b/docs/research/meerk40t-mock-mopa-spike.md @@ -0,0 +1,67 @@ +# MeerK40t mock-controller MOPA spike + +- Date: 2026-10-18 +- Upstream: `meerk40t/meerk40t` +- Commit: `d511944e08788e88077727f682957759884ff5ca` +- Scope: offline command-list encoding only + +## Result + +The pinned BalorMK controller selected its `MockConnection` and encoded a +synthetic square with explicit power, speed, 40 kHz frequency and 200 ns MOPA +pulse width. Two consecutive runs produced identical parsed command traces. +The harness supplies a synthetic 110 mm field solely for BalorMK's conversion +from mm/s to galvo units; it is not evidence that the physical lens is 110 mm. + +The trace contains: + +- `listQSwitchPeriod` value `0x01f4` (500), matching BalorMK's internal + fiber-source conversion `20000 / 40 kHz`; +- `listFiberYLPMPulseWidth` value `0x00c8` (200); +- jump and mark commands for the synthetic square; and +- a one millisecond `listLaserOnPoint` dwell represented as 100 ten-microsecond + controller units; and +- an explicit end-of-list command. + +The harness replaces PyUSB with a trap before BalorMK is imported. Any call to +USB discovery or resource disposal fails the run. The controller is also +constructed with `force_mock=True`. No USB enumeration or hardware access was +attempted. + +## Reproduce + +Check out the pinned MeerK40t commit outside this repository, then run: + +```powershell +python scripts/spike_meerk40t_mock_mopa.py C:\path\to\meerk40t +``` + +The script refuses to run against any other commit. Its JSON output includes +the upstream revision, inputs, selected transport, USB status and parsed trace. +The checked result is stored as +`tests/fixtures/meerk40t-mock-mopa-trace.json` for later golden comparisons. + +## What this proves + +- BalorMK's mock path can be forced without controller discovery. +- Operation settings preserve frequency in kHz and pulse width as the integer + value emitted by `listFiberYLPMPulseWidth` through command-list encoding. +- The tested square produces a deterministic mock trace. + +## What this does not prove + +- It does not validate the units or safe range expected by the makerspace's + specific JPT E2/M7 source. +- It does not exercise MeerK40t's graphical UI, hatch planner, correction file, + job spooler, framing, cancellation or status behavior. +- It does not establish compatibility with the physical controller, mounted + lens or correction profile. +- It does not authorize USB access, galvo motion or laser output. + +## Decision impact + +This is positive evidence for the BalorMK command encoder and mock transport, +but it is not enough to select stock MeerK40t as the workshop application. +The next useful extension of Spike A is a full cut-plan/spooler test containing +synthetic hatch geometry. Spike B, the external Rayforge no-device driver +registration proof, can proceed independently. diff --git a/docs/research/open-source-landscape.md b/docs/research/open-source-landscape.md index 49ce92f..626b982 100644 --- a/docs/research/open-source-landscape.md +++ b/docs/research/open-source-landscape.md @@ -158,8 +158,9 @@ Source inspection found particularly relevant implementation details: - imported LightBurn cut settings are mapped into Rayforge workflows, including speed, power, passes, offsets, raster interval/angle and dither; - `.lbdev` device profiles have a separate importer; -- laser steps and recipes serialize `frequency` in Hz and `pulse_width` in - microseconds; +- laser steps and recipes serialize `frequency` in Hz; the laser-step UI + describes `pulse_width` as nanoseconds, while the Ruida encoder calls the + same Ops value microseconds, so the unit contract requires clarification; - the neutral `Ops` command set contains frequency and pulse-width commands; - binary rather than G-code drivers are supported through an `OpsEncoder`; - driver state, cancel/hold, discovery, machine profiles, simulator and diff --git a/docs/research/rayforge-addon-spike.md b/docs/research/rayforge-addon-spike.md new file mode 100644 index 0000000..ec833c2 --- /dev/null +++ b/docs/research/rayforge-addon-spike.md @@ -0,0 +1,82 @@ +# Rayforge external binary-driver addon spike + +- Date: 2026-10-18 +- Upstream: `barebaric/rayforge` +- Commit: `c28918c87f8273dd1d244f5a057addc192807fa5` +- Rayforge addon API: 23 +- Scope: Windows, external addon lifecycle, no device or transport + +## Result + +An external addon loaded through Rayforge's real `AddonManager` and registered +`PirateRayJCZNoDeviceDriver`. The driver declares `uses_gcode = False`, has no +discovery specification or transport, and returns a recognizable binary test +payload from an `OpsEncoder`. + +The following lifecycle completed twice with identical JSON results: + +1. load the external addon; +2. resolve the driver by class name; +3. create its binary encoder and verify its payload; +4. disable the addon and verify driver removal; +5. re-enable it and verify re-registration; +6. reload it and verify there is exactly one registry entry; +7. package its three source/manifest files into a ZIP; and +8. disable it cleanly. + +The two result files had SHA-256 +`610f8f191526afe4685f8659c1ef2b34fac3c68a8f8088d77ca5e1fdd6080bfc`. +The checked result is `tests/fixtures/rayforge-addon-spike.json`. + +## Registration seam finding + +Rayforge's addon hook specification still exposes +`register_machines(machine_manager)`, but the inspected startup registration +table does not invoke that hook, and `MachineManager` has no method for adding +a driver class. The source-level driver registry instead exposes +`rayforge.machine.driver.register_driver(driver)`. + +The working spike therefore registers during import of the addon's worker +entry point and removes its class in `on_unload`. This survives load, disable, +enable and reload, but it relies on mutable module-level registry internals for +cleanup. Before a production addon, Rayforge should ideally expose a dedicated +addon-aware driver registry hook with supported unregister behavior. + +## Reproduce + +Create an isolated environment containing Rayforge's non-GUI runtime driver +dependencies, then run: + +```powershell +python scripts/spike_rayforge_addon.py C:\path\to\rayforge +``` + +The script refuses any Rayforge revision other than the pinned commit. + +## What this proves + +- An external addon can load a binary, non-G-code driver without patching the + Rayforge source tree. +- Its worker entry point is sufficient in headless mode. +- The driver can remain completely free of discovery and transport. +- Addon load, disable, enable and reload work when registration and cleanup are + handled explicitly. +- The addon itself can be packaged as a deterministic set of files on Windows. + +## What remains open + +- The ZIP check is addon packaging, not a proof that a complete PyInstaller or + MSIX Rayforge build discovers an externally installed addon. +- The current registration seam is functional but not a stable, addon-aware + API. Upstream feasibility should be assessed before relying on it. +- No JCZ command encoding, machine profile, UI, project reload, discovery, + transport, controller status or cancellation is implemented here. +- Nothing in this spike authorizes USB access, galvo movement or emission. + +## Decision impact + +Spike B is a qualified go: the external binary-driver boundary is viable +without a Rayforge core fork, but registration lifecycle support should be +made explicit upstream. The next technical proof is Spike C: translate a tiny +Rayforge `Ops` stream into a deterministic no-I/O JCZ trace and compare its +semantics with the pinned BalorMK mock trace. diff --git a/docs/research/rayforge-jcz-equivalence-spike.md b/docs/research/rayforge-jcz-equivalence-spike.md new file mode 100644 index 0000000..b404563 --- /dev/null +++ b/docs/research/rayforge-jcz-equivalence-spike.md @@ -0,0 +1,64 @@ +# Rayforge-to-JCZ semantic equivalence spike + +- Date: 2026-10-18 +- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5` +- MeerK40t commit: `d511944e08788e88077727f682957759884ff5ca` +- Scope: deterministic semantic encoding with no transport + +## Result + +A real Rayforge `raygeo.ops.Ops` stream was constructed with job boundaries, +50 percent power, 250 mm/s feed, 40 kHz frequency, 200 ns pulse width, a +synthetic square, a one millisecond dwell and job end. The external addon +encoded it into a deterministic semantic trace without discovery or transport. + +The encoded values matched the pinned BalorMK mock golden trace for all six +checked semantics: + +| Concept | Rayforge input | JCZ/Balor semantic value | +| --- | ---: | ---: | +| Power | `0.5` fraction | `0x0800` | +| Mark speed | `15000` mm/min | `0x0094` for synthetic 110 mm field | +| Frequency | `40000` Hz | Q-switch period `0x01f4` | +| Pulse width | `200` ns | `0x00c8` | +| Geometry | four marks | matching `0x7000`/`0x7100` coordinates | +| Dwell | `1` ms | `0x0064` ten-microsecond units | + +Two runs produced identical results with SHA-256 +`6507c887b624a28820a79cf92a49b12b518a47aca7612a17af1f4ba5302b1e8d`. +The checked result is `tests/fixtures/rayforge-jcz-equivalence.json`. + +## Unit finding + +Rayforge's laser-step UI contract labels pulse width as nanoseconds, matching +BalorMK's MOPA UI. However, the current Ruida RPA encoder docstring calls the +same `Ops.pulse_width` value microseconds. The spike follows the laser-step +contract and records this disagreement as an upstream unit-clarity gap. A +production adapter must not infer the unit from a driver-specific comment. + +## What this proves + +- Rayforge `Ops` carries the required frequency and pulse-width state into an + external binary encoder. +- The selected values can map deterministically to the same controller-level + semantics emitted by the pinned BalorMK mock implementation. +- Coordinate, speed and dwell conversions can remain inside a small encoder + boundary with no USB dependency. + +## What this does not prove + +- The semantic trace is now accompanied by separately documented 12-byte + command records; neither artifact is a complete production command list. +- The 110 mm field is synthetic and applies no lens correction. +- No machine/source/lens profile validation is implemented. +- Unsupported Ops, cancellation, status, list buffering and error recovery + still need explicit behavior and negative tests. +- No USB enumeration, controller command, motion or emission was attempted. + +## Decision impact + +Spike C passes at the semantic boundary. The result supports a Rayforge JCZ +addon architecture. A follow-up spike now covers transport-free 12-byte +command records, but ADR 0002 remains proposed until complete list behavior, +addon packaging in a complete Windows build, and the remaining stock +MeerK40t workflow assessment are addressed. diff --git a/docs/research/rayforge-jcz-negative-cases.md b/docs/research/rayforge-jcz-negative-cases.md new file mode 100644 index 0000000..c886cd2 --- /dev/null +++ b/docs/research/rayforge-jcz-negative-cases.md @@ -0,0 +1,37 @@ +# Rayforge JCZ negative safety cases + +- Date: 2026-10-18 +- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5` +- Scope: fail-closed encoder boundary, no discovery or transport + +## Result + +The transport-free JCZ semantic encoder rejected all six synthetic negative +cases before producing output: + +| Case | Rejection | +| --- | --- | +| Profile mismatch | Encoder profile identity did not match the required identity | +| Out-of-field geometry | Coordinate exceeded the synthetic 110 mm field | +| Power above one | Power was outside the Rayforge `0..1` fraction | +| Zero frequency | Frequency was not positive | +| Zero pulse width | Pulse width was not positive | +| Unsupported command | A synthetic `CUSTOM` Ops command had no explicit mapping | + +Two runs produced identical results with SHA-256 +`0adab8fb1060fde1e172c674da3d0d4ae5936b01490891b898f86cc4f7ca3c3f`. +The checked result is `tests/fixtures/rayforge-jcz-negative-cases.json`. + +## Boundary + +The profile identifier and 110 mm field are explicitly synthetic. This test +does not claim the actual machine, lens or correction identity. It proves only +that the encoder boundary can require exact identity and reject mismatches +without guessing values. + +The current validations are intentionally narrower than a production policy. +Real minimum/maximum frequency and pulse-width limits require verified source +and machine evidence. Until then, the spike rejects non-positive values but +does not invent an upper limit. + +No controller discovery, USB access, motion, framing or emission occurred. diff --git a/docs/research/rayforge-jcz-packet-spike.md b/docs/research/rayforge-jcz-packet-spike.md new file mode 100644 index 0000000..006388c --- /dev/null +++ b/docs/research/rayforge-jcz-packet-spike.md @@ -0,0 +1,47 @@ +# Rayforge-to-JCZ 12-byte record spike + +- Date: 2026-10-18 +- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5` +- MeerK40t commit: `d511944e08788e88077727f682957759884ff5ca` +- Scope: deterministic command-record packing with no transport + +## Result + +The external Rayforge addon now packs the synthetic operation stream into +little-endian JCZ command records of six unsigned 16-bit words (12 bytes). +The opcode and value fields agree with the pinned BalorMK mock trace for power, +mark and jump speed, frequency, MOPA pulse width, the square coordinates and +distances, one millisecond dwell, and end-of-list. + +Two independent runs produced identical output with SHA-256 +`b3d34600daac7060c3d3b28edf1627813d3bc518b1a64be11367f8e2106a51c6`. +The checked records are in +`tests/fixtures/rayforge-jcz-equivalence.json` and are asserted byte-for-byte by +the standard-library test suite. + +## Safety boundary + +This is an independently written, transport-free encoder spike. It imports no +USB, serial or socket transport, performs no discovery, and cannot send the +records anywhere. A synthetic profile identity must match before encoding. +Coordinates outside the synthetic 110 mm field and invalid or unsupported +operations continue to fail closed. + +## Remaining gaps + +- Records are emitted in Rayforge operation order. A production adapter must + define controller-safe state ordering from evidence. +- List framing, fixed-size buffers, padding, upload, status, cancellation and + recovery are absent. +- The 110 mm coordinate mapping is synthetic and has no lens correction. +- Machine-specific bounds and the pulse-width unit contract still require + authoritative evidence; they are not guessed here. +- No hardware compatibility or safe marking behavior is established. + +## Decision impact + +The result advances the Rayforge addon route beyond semantic JSON traces: the +same operation model can produce deterministic controller-shaped records at a +narrow encoder boundary. ADR 0002 remains proposed because stock application +workflow coverage, complete packet/list behavior, packaging in a full Windows +build and the remaining safety proof obligations are still open. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6cb16c1..e73fc0d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Product and research roadmap -Last updated: 2026-10-17 +Last updated: 2026-10-18 ## Destination @@ -104,6 +104,41 @@ The first work is entirely offline: - identify only the missing profile, validation, diagnostic and safety concepts instead of designing replacements for existing ones. +The first controller-level part of the MeerK40t mock evaluation is recorded in +[the mock MOPA spike](research/meerk40t-mock-mopa-spike.md). It confirms +deterministic frequency, pulse-width and square-geometry command-list encoding +through BalorMK's forced mock transport. Full cut-plan/spooler and hatch +coverage remains open, so this does not yet complete the stock-application +evaluation or settle ADR 0002. + +The first Rayforge addon proof is recorded in +[the external binary-driver addon spike](research/rayforge-addon-spike.md). +An external addon can register a no-discovery, no-transport binary driver and +survive load, disable, enable and reload without patching Rayforge core. The +working registration path is an import-time call to the module-level driver +registry plus explicit unload cleanup, not the documented +`register_machines(machine_manager)` hook. Full application packaging and a +stable addon-aware unregister API remain open. + +[The Rayforge-to-JCZ equivalence spike](research/rayforge-jcz-equivalence-spike.md) +confirms deterministic semantic agreement with the pinned BalorMK mock trace +for power, speed, frequency, MOPA pulse width, square coordinates and a one +millisecond dwell. It deliberately stops before production packet encoding, +lens correction or transport and records an upstream pulse-width unit +documentation conflict that must be resolved explicitly. + +[Negative JCZ encoder cases](research/rayforge-jcz-negative-cases.md) now prove +fail-closed handling for a synthetic profile mismatch, out-of-field geometry, +power outside Rayforge's fraction range, non-positive frequency and pulse +width, and unsupported Ops. Hardware-specific upper limits remain unknown and +are deliberately not guessed. + +[The transport-free JCZ record spike](research/rayforge-jcz-packet-spike.md) +packs the same operation stream into deterministic little-endian 12-byte +records whose opcodes, values, coordinates and distances match the pinned +BalorMK mock evidence. It deliberately omits list buffering and all transport; +controller-safe state ordering remains an explicit evidence gap. + The architecture decision remains proposed until these spikes produce evidence. An independent app requires a documented gap analysis showing why neither existing platform can be extended responsibly. diff --git a/scripts/generate_brand_assets.py b/scripts/generate_brand_assets.py index 7419122..e882639 100644 --- a/scripts/generate_brand_assets.py +++ b/scripts/generate_brand_assets.py @@ -316,7 +316,9 @@ def generate_ios(source: Image.Image) -> None: contents = {"images": images, "info": {"author": "xcode", "version": 1}} directory.mkdir(parents=True, exist_ok=True) (directory / "Contents.json").write_text( - json.dumps(contents, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + json.dumps(contents, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + newline="\n", ) @@ -348,8 +350,12 @@ def generate_android(source: Image.Image) -> None: """ anydpi = OUTPUT / "android" / "mipmap-anydpi-v26" anydpi.mkdir(parents=True, exist_ok=True) - (anydpi / "ic_launcher.xml").write_text(adaptive_xml, encoding="utf-8") - (anydpi / "ic_launcher_round.xml").write_text(adaptive_xml, encoding="utf-8") + (anydpi / "ic_launcher.xml").write_text( + adaptive_xml, encoding="utf-8", newline="\n" + ) + (anydpi / "ic_launcher_round.xml").write_text( + adaptive_xml, encoding="utf-8", newline="\n" + ) values = OUTPUT / "android" / "values" values.mkdir(parents=True, exist_ok=True) (values / "ic_launcher_background.xml").write_text( @@ -359,6 +365,7 @@ def generate_android(source: Image.Image) -> None: """, encoding="utf-8", + newline="\n", ) @@ -434,7 +441,9 @@ def generate() -> None: "display": "standalone", } (web / "site.webmanifest").write_text( - json.dumps(webmanifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + json.dumps(webmanifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + newline="\n", ) generate_windows(source) @@ -466,7 +475,9 @@ def generate() -> None: "files": files, } (OUTPUT / "manifest.json").write_text( - json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + newline="\n", ) write_branding_readme(files) diff --git a/scripts/spike_meerk40t_mock_mopa.py b/scripts/spike_meerk40t_mock_mopa.py new file mode 100644 index 0000000..0093966 --- /dev/null +++ b/scripts/spike_meerk40t_mock_mopa.py @@ -0,0 +1,201 @@ +"""Run a deterministic, hardware-free BalorMK MOPA command-list spike. + +The script imports a separately checked-out MeerK40t tree. It installs an +in-process USB trap before importing BalorMK, forces the controller's mock +transport, and never enumerates USB devices. +""" + +from __future__ import annotations + +import argparse +import json +import struct +import subprocess +import sys +import types +from pathlib import Path + + +PINNED_COMMIT = "d511944e08788e88077727f682957759884ff5ca" + + +class _Channel: + def __init__(self, name: str, log: list[str]): + self.name = name + self.log = log + self._ = lambda value: value + + def __call__(self, value: str) -> None: + self.log.append(f"{self.name}: {value}") + + def watch(self, _handler) -> None: + return None + + +class _Service: + safe_label = "offline-mopa-spike" + source = "fiber" + pulse_width_enabled = True + default_pulse_width = 4 + default_rapid_speed = 2000.0 + default_power = 100.0 + default_frequency = 30.0 + default_fpk = 10.0 + default_speed = 100.0 + delay_laser_on = 100 + delay_laser_off = 100 + delay_polygon = 100 + signal_updates = False + + class _SyntheticView: + """Deterministic 110 mm field used only for speed conversion.""" + + @staticmethod + def position(_x, _y, *, vector=False, margins=True): + del vector, margins + galvos_per_mm = 65535.0 / 110.0 + return galvos_per_mm, galvos_per_mm + + def __init__(self) -> None: + self.log: list[str] = [] + self.channels: dict[str, _Channel] = {} + self.view = self._SyntheticView() + + def channel(self, name: str, **_kwargs) -> _Channel: + return self.channels.setdefault(name, _Channel(name, self.log)) + + def signal(self, *_args) -> None: + return None + + def setting(self, _kind, name: str, default): + return getattr(self, name, default) + + +def _install_usb_trap() -> None: + class USBError(Exception): + backend_error_code = None + + class NoBackendError(USBError): + pass + + def forbidden(*_args, **_kwargs): + raise AssertionError("real USB access attempted during mock-only spike") + + usb = types.ModuleType("usb") + core = types.ModuleType("usb.core") + util = types.ModuleType("usb.util") + backend = types.ModuleType("usb.backend") + libusb1 = types.ModuleType("usb.backend.libusb1") + core.USBError = USBError + core.NoBackendError = NoBackendError + core.find = forbidden + util.dispose_resources = forbidden + libusb1.LIBUSB_ERROR_ACCESS = -3 + libusb1.LIBUSB_ERROR_NOT_FOUND = -5 + usb.core = core + usb.util = util + sys.modules.update( + { + "usb": usb, + "usb.core": core, + "usb.util": util, + "usb.backend": backend, + "usb.backend.libusb1": libusb1, + } + ) + + +def _commit_of(source: Path) -> str: + result = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def run(source: Path) -> dict[str, object]: + commit = _commit_of(source) + if commit != PINNED_COMMIT: + raise RuntimeError(f"expected MeerK40t {PINNED_COMMIT}, found {commit}") + + _install_usb_trap() + sys.path.insert(0, str(source)) + from meerk40t.balormk.controller import GalvoController + from meerk40t.balormk.mock_connection import MockConnection + + service = _Service() + controller = GalvoController(service, force_mock=True) + controller.init_laser = lambda: None + controller.connect_if_needed() + if not isinstance(controller.connection, MockConnection): + raise AssertionError("controller did not select MockConnection") + + parsed_packets: list[str] = [] + controller.connection.send = parsed_packets.append + controller._list_new() + controller.set_settings( + { + "power": 500, + "speed": 250.0, + "frequency": 40.0, + "pulse_width_enabled": True, + "pulse_width": 200, + } + ) + controller.goto(0x7000, 0x7000) + controller.mark(0x7100, 0x7000) + controller.mark(0x7100, 0x7100) + controller.mark(0x7000, 0x7100) + controller.mark(0x7000, 0x7000) + controller.list_laser_on_point(100) # 1 ms in BalorMK's 10 us units. + controller.list_end_of_list() + # Make the mock's status reply deterministic and ready. Without this, + # upstream MockConnection returns random bytes when no response is implied. + controller.connection.set_implied_response(struct.pack("<4H", 0, 0, 0, 0x20)) + controller._list_end() + + trace = "\n".join(parsed_packets) + required = { + "frequency_40_khz": "801b:01f4:0000:0000:0000:0000 listQSwitchPeriod", + "pulse_width_200_ns": "8026:00c8:0000:0000:0000:0000 listFiberYLPMPulseWidth", + "geometry": "listMarkTo", + "dwell_1_ms": "8003:0064:0000:0000:0000:0000 listLaserOnPoint", + "end": "listEndOfList", + } + for label, marker in required.items(): + if marker not in trace: + raise AssertionError(f"missing {label}: {marker}\nactual trace:\n{trace}") + + return { + "upstream": "meerk40t/meerk40t", + "commit": commit, + "transport": type(controller.connection).__name__, + "usb_access": "trapped; none attempted", + "input": { + "power_per_mille": 500, + "speed_mm_per_s": 250.0, + "frequency_khz": 40.0, + "pulse_width_ns": 200, + "geometry": "closed synthetic square in controller coordinates", + }, + "trace": trace.splitlines(), + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path, help="pinned MeerK40t checkout") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = run(args.source.resolve()) + rendered = json.dumps(result, indent=2) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/spike_rayforge_addon.py b/scripts/spike_rayforge_addon.py new file mode 100644 index 0000000..f82982a --- /dev/null +++ b/scripts/spike_rayforge_addon.py @@ -0,0 +1,143 @@ +"""Exercise the external Rayforge addon and binary-driver lifecycle.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path +from unittest.mock import MagicMock + + +PINNED_COMMIT = "c28918c87f8273dd1d244f5a057addc192807fa5" +ADDON_NAME = "the_pirate_ray_jcz" +DRIVER_NAME = "PirateRayJCZNoDeviceDriver" + + +def _commit_of(source: Path) -> str: + result = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def run(source: Path, addon_path: Path) -> dict[str, object]: + commit = _commit_of(source) + if commit != PINNED_COMMIT: + raise RuntimeError(f"expected Rayforge {PINNED_COMMIT}, found {commit}") + + sys.path.insert(0, str(source)) + from rayforge.addon_mgr.addon import Addon + from rayforge.addon_mgr.addon_manager import AddonManager + from rayforge.core.addon_config import AddonConfig + from rayforge.core.hooks import RayforgeSpecs + from rayforge.machine import driver as driver_registry + import pluggy + + parsed = Addon.load_from_directory(addon_path, version="0.1.0") + parsed.validate() + + scratch = Path.cwd() / "reports" + scratch.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="tpr-rayforge-addon-", dir=scratch + ) as temp: + root = Path(temp) + config = AddonConfig(root / "config") + plugin_mgr = pluggy.PluginManager("rayforge") + plugin_mgr.add_hookspecs(RayforgeSpecs) + manager = AddonManager( + [addon_path.parent], + addon_path.parent, + plugin_mgr, + MagicMock(), + addon_config=config, + registries={}, + ) + + manager.load_addon(addon_path, worker_only=True, version="0.1.0") + driver = driver_registry.get_driver_cls(DRIVER_NAME) + if driver.__name__ != DRIVER_NAME or driver.uses_gcode: + raise AssertionError("external binary driver was not registered") + encoded = driver.create_encoder(None).encode(None) + if not encoded.driver_data.get("binary"): + raise AssertionError("binary encoder payload missing") + if encoded.driver_data.get("transport") is not None: + raise AssertionError("offline encoder unexpectedly has transport") + + if not manager.disable_addon(ADDON_NAME): + raise AssertionError("addon did not disable cleanly") + if DRIVER_NAME in driver_registry.driver_by_classname: + raise AssertionError("driver remained registered after disable") + + if not manager.enable_addon(ADDON_NAME): + raise AssertionError("addon did not re-enable cleanly") + if driver_registry.get_driver_cls(DRIVER_NAME).__name__ != DRIVER_NAME: + raise AssertionError("driver did not re-register after enable") + + if not manager.reload_addon(ADDON_NAME): + raise AssertionError("addon did not reload cleanly") + matches = [ + cls for cls in driver_registry.drivers if cls.__name__ == DRIVER_NAME + ] + if len(matches) != 1: + raise AssertionError("driver registry duplicated on reload") + + package = root / "the_pirate_ray_jcz-0.1.0.zip" + with zipfile.ZipFile(package, "w", zipfile.ZIP_DEFLATED) as archive: + for path in sorted(addon_path.rglob("*")): + if path.is_file() and "__pycache__" not in path.parts: + archive.write(path, path.relative_to(addon_path)) + with zipfile.ZipFile(package) as archive: + packaged_files = sorted(archive.namelist()) + + manager.disable_addon(ADDON_NAME) + + return { + "upstream": "barebaric/rayforge", + "commit": commit, + "rayforge_api_version": parsed.metadata.api_version, + "addon": ADDON_NAME, + "driver": DRIVER_NAME, + "uses_gcode": False, + "discovery": None, + "transport": None, + "binary_payload_hex": ( + b'[{"command":"offline_probe"}]'.hex() + ), + "lifecycle": ["load", "disable", "enable", "reload", "disable"], + "packaged_files": packaged_files, + "registration_seam": "worker import side effect + on_unload cleanup", + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path, help="pinned Rayforge checkout") + parser.add_argument( + "--addon", + type=Path, + default=( + Path(__file__).parents[1] + / "spikes" + / "rayforge-jcz-no-device-addon" + ), + ) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = run(args.source.resolve(), args.addon.resolve()) + rendered = json.dumps(result, indent=2) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/spike_rayforge_jcz_equivalence.py b/scripts/spike_rayforge_jcz_equivalence.py new file mode 100644 index 0000000..580c9fd --- /dev/null +++ b/scripts/spike_rayforge_jcz_equivalence.py @@ -0,0 +1,152 @@ +"""Compare a synthetic Rayforge Ops stream with the BalorMK golden trace.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + + +RAYFORGE_COMMIT = "c28918c87f8273dd1d244f5a057addc192807fa5" + + +def _commit_of(source: Path) -> str: + result = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _load_encoder(addon: Path): + worker = addon / "pirate_ray_jcz" / "worker.py" + spec = importlib.util.spec_from_file_location("tpr_jcz_worker", worker) + if spec is None or spec.loader is None: + raise RuntimeError("could not load JCZ spike encoder") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.OfflineJCZEncoder + + +def run(source: Path, addon: Path, balor_fixture: Path) -> dict[str, object]: + commit = _commit_of(source) + if commit != RAYFORGE_COMMIT: + raise RuntimeError(f"expected Rayforge {RAYFORGE_COMMIT}, found {commit}") + sys.path.insert(0, str(source)) + + from raygeo.ops import Ops + + Encoder = _load_encoder(addon) + to_mm = lambda word: word * Encoder.FIELD_MM / 65535.0 + + ops = Ops() + ops.job_start() + ops.set_power(0.5) + ops.set_feed_rate(250.0 * 60.0) + ops.set_rapid_rate(2000.0 * 60.0) + ops.set_frequency(40_000) + ops.set_pulse_width(200) + ops.move_to(to_mm(0x7000), to_mm(0x7000), 0.0) + ops.line_to(to_mm(0x7100), to_mm(0x7000), 0.0) + ops.line_to(to_mm(0x7100), to_mm(0x7100), 0.0) + ops.line_to(to_mm(0x7000), to_mm(0x7100), 0.0) + ops.line_to(to_mm(0x7000), to_mm(0x7000), 0.0) + ops.dwell(1.0) + ops.job_end() + + encoded = Encoder().encode(ops) + semantic = encoded.driver_data["semantic_trace"] + by_command: dict[str, list[dict]] = {} + for item in semantic: + by_command.setdefault(item["command"], []).append(item) + + balor = json.loads(balor_fixture.read_text(encoding="utf-8")) + balor_trace = "\n".join(balor["trace"]) + checks = { + "power_50_percent": ( + by_command["power"][0]["balor_word"] == 0x0800 + and "8012:0800" in balor_trace + ), + "speed_250_mm_s": ( + by_command["mark_speed"][0]["balor_word"] == 0x0094 + and "800c:0094" in balor_trace + ), + "frequency_40_khz": ( + by_command["frequency"][0]["balor_qswitch_period"] == 0x01F4 + and "801b:01f4" in balor_trace + ), + "pulse_width_200_ns": ( + by_command["pulse_width"][0]["nanoseconds"] == 200 + and "8026:00c8" in balor_trace + ), + "square_coordinates": ( + [(item["x"], item["y"]) for item in by_command["mark"]] + == [ + (0x7100, 0x7000), + (0x7100, 0x7100), + (0x7000, 0x7100), + (0x7000, 0x7000), + ] + and balor_trace.count("listMarkTo") == 4 + ), + "dwell_1_ms": ( + by_command["laser_on_point"][0]["balor_10us_units"] == 100 + and "8003:0064" in balor_trace + ), + } + if not all(checks.values()): + raise AssertionError(f"semantic mismatch: {checks}") + + return { + "rayforge_commit": commit, + "balormk_commit": balor["commit"], + "transport": None, + "synthetic_field_mm": Encoder.FIELD_MM, + "rayforge_ops_count": ops.len(), + "semantic_trace": semantic, + "command_records_hex": encoded.driver_data["command_records_hex"], + "balormk_equivalence": checks, + "limitations": [ + "12-byte command records only; no list buffering or transport", + "record ordering follows the Rayforge Ops stream", + "synthetic field mapping; no lens correction", + "pulse width treated as nanoseconds from LaserStep UI contract", + ], + } + + +def main() -> None: + root = Path(__file__).parents[1] + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path, help="pinned Rayforge checkout") + parser.add_argument( + "--addon", + type=Path, + default=root / "spikes" / "rayforge-jcz-no-device-addon", + ) + parser.add_argument( + "--balor-fixture", + type=Path, + default=root / "tests" / "fixtures" / "meerk40t-mock-mopa-trace.json", + ) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = run( + args.source.resolve(), + args.addon.resolve(), + args.balor_fixture.resolve(), + ) + rendered = json.dumps(result, indent=2) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/spike_rayforge_jcz_negative_cases.py b/scripts/spike_rayforge_jcz_negative_cases.py new file mode 100644 index 0000000..bed7214 --- /dev/null +++ b/scripts/spike_rayforge_jcz_negative_cases.py @@ -0,0 +1,127 @@ +"""Run fail-closed cases against the transport-free Rayforge JCZ encoder.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + + +RAYFORGE_COMMIT = "c28918c87f8273dd1d244f5a057addc192807fa5" + + +def _commit_of(source: Path) -> str: + result = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _load_encoder(addon: Path): + path = addon / "pirate_ray_jcz" / "worker.py" + spec = importlib.util.spec_from_file_location("tpr_jcz_negative", path) + if spec is None or spec.loader is None: + raise RuntimeError("could not load JCZ spike encoder") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.OfflineJCZEncoder + + +def run(source: Path, addon: Path) -> dict[str, object]: + commit = _commit_of(source) + if commit != RAYFORGE_COMMIT: + raise RuntimeError(f"expected Rayforge {RAYFORGE_COMMIT}, found {commit}") + sys.path.insert(0, str(source)) + from raygeo.ops import Ops + + Encoder = _load_encoder(addon) + + cases = [] + + def expect_rejection(name, encoder, ops, expected_message): + try: + encoder.encode(ops) + except ValueError as error: + message = str(error) + if expected_message not in message: + raise AssertionError( + f"{name} rejected for unexpected reason: {message}" + ) from error + cases.append( + {"case": name, "result": "rejected", "reason": message} + ) + return + raise AssertionError(f"{name} was not rejected") + + empty = Ops() + expect_rejection( + "profile_mismatch", + Encoder(profile_id="synthetic-wrong-profile"), + empty, + "profile mismatch", + ) + + outside = Ops() + outside.move_to(Encoder.FIELD_MM + 0.001, 0.0, 0.0) + expect_rejection( + "out_of_field_geometry", Encoder(), outside, "outside" + ) + + power = Ops() + power.set_power(1.001) + expect_rejection("power_above_one", Encoder(), power, "power") + + frequency = Ops() + frequency.set_frequency(0) + expect_rejection( + "non_positive_frequency", Encoder(), frequency, "frequency" + ) + + pulse = Ops() + pulse.set_pulse_width(0) + expect_rejection( + "non_positive_pulse_width", Encoder(), pulse, "pulse width" + ) + + unsupported = Ops() + unsupported.custom("synthetic-unsupported-command") + expect_rejection( + "unsupported_ops_command", Encoder(), unsupported, "unsupported" + ) + + return { + "rayforge_commit": commit, + "profile": Encoder.SYNTHETIC_PROFILE_ID, + "transport": None, + "cases": cases, + "all_failed_closed": len(cases) == 6, + } + + +def main() -> None: + root = Path(__file__).parents[1] + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path, help="pinned Rayforge checkout") + parser.add_argument( + "--addon", + type=Path, + default=root / "spikes" / "rayforge-jcz-no-device-addon", + ) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = run(args.source.resolve(), args.addon.resolve()) + rendered = json.dumps(result, indent=2) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + + +if __name__ == "__main__": + main() diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/__init__.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/__init__.py new file mode 100644 index 0000000..6b56b07 --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/__init__.py @@ -0,0 +1 @@ +"""The Pirate Ray JCZ offline Rayforge spike.""" diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py new file mode 100644 index 0000000..05e315a --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py @@ -0,0 +1,245 @@ +"""Hardware-free Rayforge binary driver registration proof. + +This is deliberately not a controller implementation. It has no discovery +specification, transport, USB import, or machine I/O. +""" + +import json +import math +import struct +from typing import Any + +from rayforge.core.hooks import hookimpl +from rayforge.machine import driver as driver_registry +from rayforge.machine.driver import NoDeviceDriver +from rayforge.pipeline.encoder.base import ( + EncodedOutput, + MachineCodeOpMap, + OpsEncoder, +) + + +class OfflineJCZEncoder(OpsEncoder): + """Encode Rayforge Ops to a deterministic, transport-free JCZ trace.""" + + FIELD_MM = 110.0 + SYNTHETIC_PROFILE_ID = "synthetic-jpt-mopa-110-v1" + + OPCODES = { + "jump": 0x8001, + "end_of_list": 0x8002, + "laser_on_point": 0x8003, + "mark": 0x8005, + "jump_speed": 0x8006, + "frequency": 0x801B, + "power": 0x8012, + "mark_speed": 0x800C, + "pulse_width": 0x8026, + } + + def __init__( + self, + profile_id: str = SYNTHETIC_PROFILE_ID, + expected_profile_id: str = SYNTHETIC_PROFILE_ID, + ) -> None: + self.profile_id = profile_id + self.expected_profile_id = expected_profile_id + + def _validate_profile(self) -> None: + if self.profile_id != self.expected_profile_id: + raise ValueError( + "profile mismatch: encoder refuses to produce a JCZ trace" + ) + + @staticmethod + def _record(command: int, *values: int) -> bytes: + words = (command, *values, 0, 0, 0, 0, 0)[:6] + if any(not 0 <= value <= 0xFFFF for value in words): + raise ValueError("JCZ command word is outside the unsigned 16-bit range") + return struct.pack("<6H", *words) + + def _encode_records(self, trace: list[dict[str, Any]]) -> list[bytes]: + records = [] + last_x = 0x8000 + last_y = 0x8000 + for item in trace: + command = item["command"] + if command == "job_start": + continue + if command == "job_end": + records.append(self._record(self.OPCODES["end_of_list"])) + elif command == "pulse_width": + records.append( + self._record( + self.OPCODES[command], round(item["nanoseconds"]) + ) + ) + elif command == "frequency": + records.append( + self._record( + self.OPCODES[command], item["balor_qswitch_period"] + ) + ) + elif command in ("power", "mark_speed", "jump_speed"): + records.append( + self._record(self.OPCODES[command], item["balor_word"]) + ) + elif command in ("jump", "mark"): + x = item["x"] + y = item["y"] + distance = min(0xFFFF, int(math.hypot(x - last_x, y - last_y))) + records.append(self._record(self.OPCODES[command], x, y, 0, distance)) + last_x, last_y = x, y + elif command == "laser_on_point": + records.append( + self._record(self.OPCODES[command], item["balor_10us_units"]) + ) + elif command == "offline_probe": + continue + else: + raise ValueError(f"cannot packetize semantic command: {command}") + return records + + @classmethod + def _coordinate(cls, value_mm: float) -> int: + if not 0.0 <= value_mm <= cls.FIELD_MM: + raise ValueError("coordinate is outside the synthetic 110 mm field") + return round(value_mm * 65535.0 / cls.FIELD_MM) + + def encode(self, ops: Any, *args, **kwargs) -> EncodedOutput: + del args, kwargs + self._validate_profile() + if ops is None: + trace = [{"command": "offline_probe"}] + op_count = 0 + else: + trace = [] + for command in ops.to_dict()["commands"]: + kind = command["type"] + if kind in ("JOB_START", "JOB_END"): + trace.append({"command": kind.lower()}) + elif kind == "SET_POWER": + fraction = float(command["power"]) + if not 0.0 <= fraction <= 1.0: + raise ValueError("power must be between 0 and 1") + trace.append( + { + "command": "power", + "fraction": fraction, + "balor_word": round(fraction * 0xFFF), + } + ) + elif kind in ("SET_FEED_RATE", "SET_RAPID_RATE"): + speed_mm_s = float(command["speed"]) / 60.0 + trace.append( + { + "command": ( + "mark_speed" + if kind == "SET_FEED_RATE" + else "jump_speed" + ), + "mm_per_s": speed_mm_s, + "balor_word": int( + speed_mm_s * (65535.0 / self.FIELD_MM) / 1000.0 + ), + } + ) + elif kind == "SET_FREQUENCY": + frequency_hz = float(command["frequency"]) + if frequency_hz <= 0: + raise ValueError("frequency must be positive") + trace.append( + { + "command": "frequency", + "hz": frequency_hz, + "balor_qswitch_period": round( + 20_000_000.0 / frequency_hz + ), + } + ) + elif kind == "SET_PULSE_WIDTH": + width_ns = float(command["pulse_width"]) + if width_ns <= 0: + raise ValueError("pulse width must be positive") + trace.append( + {"command": "pulse_width", "nanoseconds": width_ns} + ) + elif kind in ("MOVE_TO", "LINE_TO"): + x, y, _z = command["end"] + trace.append( + { + "command": "jump" if kind == "MOVE_TO" else "mark", + "x": self._coordinate(float(x)), + "y": self._coordinate(float(y)), + } + ) + elif kind == "DWELL": + duration_ms = float(command["duration_ms"]) + trace.append( + { + "command": "laser_on_point", + "duration_ms": duration_ms, + "balor_10us_units": round(duration_ms * 100.0), + } + ) + else: + raise ValueError(f"unsupported Ops command: {kind}") + op_count = len(trace) + + records = self._encode_records(trace) + payload = b"".join(records) + if ops is None: + payload = json.dumps( + trace, sort_keys=True, separators=(",", ":") + ).encode("ascii") + return EncodedOutput( + text=payload.hex(), + op_map=MachineCodeOpMap.from_lists( + [(index, 1) for index in range(op_count)], + list(range(op_count)), + ), + driver_data={ + "binary": payload, + "command_records_hex": [record.hex() for record in records], + "semantic_trace": trace, + "transport": None, + }, + ) + + +class PirateRayJCZNoDeviceDriver(NoDeviceDriver): + label = "The Pirate Ray JCZ (offline)" + subtitle = "No discovery or hardware transport" + uses_gcode = False + DISCOVERY = None + + @classmethod + def create_encoder(cls, machine) -> OpsEncoder: + del machine + return OfflineJCZEncoder() + + +def _register() -> None: + name = PirateRayJCZNoDeviceDriver.__name__ + if name not in driver_registry.driver_by_classname: + driver_registry.register_driver(PirateRayJCZNoDeviceDriver) + + +def _unregister() -> None: + name = PirateRayJCZNoDeviceDriver.__name__ + registered = driver_registry.driver_by_classname.get(name) + if registered is PirateRayJCZNoDeviceDriver: + del driver_registry.driver_by_classname[name] + driver_registry.drivers[:] = [ + item for item in driver_registry.drivers if item is not registered + ] + + +# Rayforge currently has no driver registry in call_registration_hooks(). +# Registration therefore has to happen when the worker entry point is loaded. +_register() + + +@hookimpl +def on_unload() -> None: + _unregister() diff --git a/spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml b/spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml new file mode 100644 index 0000000..4c55270 --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml @@ -0,0 +1,12 @@ +name: the_pirate_ray_jcz +display_name: The Pirate Ray JCZ (offline spike) +description: Hardware-free binary-driver registration proof for Rayforge. +version: 0.1.0 +api_version: 23 +depends: + - rayforge>=1.0.0 +author: + name: The Pirate Ray contributors +provides: + worker: pirate_ray_jcz.worker +maturity: experimental diff --git a/tests/fixtures/meerk40t-mock-mopa-trace.json b/tests/fixtures/meerk40t-mock-mopa-trace.json new file mode 100644 index 0000000..60be4a0 --- /dev/null +++ b/tests/fixtures/meerk40t-mock-mopa-trace.json @@ -0,0 +1,33 @@ +{ + "upstream": "meerk40t/meerk40t", + "commit": "d511944e08788e88077727f682957759884ff5ca", + "transport": "MockConnection", + "usb_access": "trapped; none attempted", + "input": { + "power_per_mille": 500, + "speed_mm_per_s": 250.0, + "frequency_khz": 40.0, + "pulse_width_ns": 200, + "geometry": "closed synthetic square in controller coordinates" + }, + "trace": [ + "0007:0000:0000:0000:0000:0000 GetVersion", + "8026:00c8:0000:0000:0000:0000 listFiberYLPMPulseWidth", + "8006:04a7:0000:0000:0000:0000 listJumpSpeed", + "801b:01f4:0000:0000:0000:0000 listQSwitchPeriod", + "8012:0800:0000:0000:0000:0000 listMarkCurrent", + "800c:0094:0000:0000:0000:0000 listMarkSpeed", + "8007:0064:0000:0000:0000:0000 listLaserOnDelay", + "8008:0064:0000:0000:0000:0000 listLaserOffDelay", + "800f:000a:0000:0000:0000:0000 listPolygonDelay", + "8001:7000:7000:0000:16a0:0000 listJumpTo", + "8005:7100:7000:0000:0100:0000 listMarkTo", + "8005:7100:7100:0000:0100:0000 listMarkTo", + "8005:7000:7100:0000:0100:0000 listMarkTo", + "8005:7000:7000:0000:0100:0000 listMarkTo", + "8003:0064:0000:0000:0000:0000 listLaserOnPoint", + "8002:0000:0000:0000:0000:0000 listEndOfList", + "... repeated 241 times ...", + "0019:0000:0000:0000:0000:0000 SetEndOfList" + ] +} diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json new file mode 100644 index 0000000..a7bda93 --- /dev/null +++ b/tests/fixtures/rayforge-addon-spike.json @@ -0,0 +1,24 @@ +{ + "upstream": "barebaric/rayforge", + "commit": "c28918c87f8273dd1d244f5a057addc192807fa5", + "rayforge_api_version": 23, + "addon": "the_pirate_ray_jcz", + "driver": "PirateRayJCZNoDeviceDriver", + "uses_gcode": false, + "discovery": null, + "transport": null, + "binary_payload_hex": "5b7b22636f6d6d616e64223a226f66666c696e655f70726f6265227d5d", + "lifecycle": [ + "load", + "disable", + "enable", + "reload", + "disable" + ], + "packaged_files": [ + "pirate_ray_jcz/__init__.py", + "pirate_ray_jcz/worker.py", + "rayforge-addon.yaml" + ], + "registration_seam": "worker import side effect + on_unload cleanup" +} diff --git a/tests/fixtures/rayforge-jcz-equivalence.json b/tests/fixtures/rayforge-jcz-equivalence.json new file mode 100644 index 0000000..43133a3 --- /dev/null +++ b/tests/fixtures/rayforge-jcz-equivalence.json @@ -0,0 +1,50 @@ +{ + "rayforge_commit": "c28918c87f8273dd1d244f5a057addc192807fa5", + "balormk_commit": "d511944e08788e88077727f682957759884ff5ca", + "transport": null, + "synthetic_field_mm": 110.0, + "rayforge_ops_count": 13, + "semantic_trace": [ + {"command": "job_start"}, + {"command": "power", "fraction": 0.5, "balor_word": 2048}, + {"command": "mark_speed", "mm_per_s": 250.0, "balor_word": 148}, + {"command": "jump_speed", "mm_per_s": 2000.0, "balor_word": 1191}, + {"command": "frequency", "hz": 40000.0, "balor_qswitch_period": 500}, + {"command": "pulse_width", "nanoseconds": 200.0}, + {"command": "jump", "x": 28672, "y": 28672}, + {"command": "mark", "x": 28928, "y": 28672}, + {"command": "mark", "x": 28928, "y": 28928}, + {"command": "mark", "x": 28672, "y": 28928}, + {"command": "mark", "x": 28672, "y": 28672}, + {"command": "laser_on_point", "duration_ms": 1.0, "balor_10us_units": 100}, + {"command": "job_end"} + ], + "command_records_hex": [ + "128000080000000000000000", + "0c8094000000000000000000", + "0680a7040000000000000000", + "1b80f4010000000000000000", + "2680c8000000000000000000", + "0180007000700000a0160000", + "058000710070000000010000", + "058000710071000000010000", + "058000700071000000010000", + "058000700070000000010000", + "038064000000000000000000", + "028000000000000000000000" + ], + "balormk_equivalence": { + "power_50_percent": true, + "speed_250_mm_s": true, + "frequency_40_khz": true, + "pulse_width_200_ns": true, + "square_coordinates": true, + "dwell_1_ms": true + }, + "limitations": [ + "12-byte command records only; no list buffering or transport", + "record ordering follows the Rayforge Ops stream", + "synthetic field mapping; no lens correction", + "pulse width treated as nanoseconds from LaserStep UI contract" + ] +} diff --git a/tests/fixtures/rayforge-jcz-negative-cases.json b/tests/fixtures/rayforge-jcz-negative-cases.json new file mode 100644 index 0000000..7c948be --- /dev/null +++ b/tests/fixtures/rayforge-jcz-negative-cases.json @@ -0,0 +1,38 @@ +{ + "rayforge_commit": "c28918c87f8273dd1d244f5a057addc192807fa5", + "profile": "synthetic-jpt-mopa-110-v1", + "transport": null, + "cases": [ + { + "case": "profile_mismatch", + "result": "rejected", + "reason": "profile mismatch: encoder refuses to produce a JCZ trace" + }, + { + "case": "out_of_field_geometry", + "result": "rejected", + "reason": "coordinate is outside the synthetic 110 mm field" + }, + { + "case": "power_above_one", + "result": "rejected", + "reason": "power must be between 0 and 1" + }, + { + "case": "non_positive_frequency", + "result": "rejected", + "reason": "frequency must be positive" + }, + { + "case": "non_positive_pulse_width", + "result": "rejected", + "reason": "pulse width must be positive" + }, + { + "case": "unsupported_ops_command", + "result": "rejected", + "reason": "unsupported Ops command: CUSTOM" + } + ], + "all_failed_closed": true +} diff --git a/tests/test_meerk40t_mock_spike.py b/tests/test_meerk40t_mock_spike.py new file mode 100644 index 0000000..7bd258b --- /dev/null +++ b/tests/test_meerk40t_mock_spike.py @@ -0,0 +1,44 @@ +import importlib.util +import json +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import patch + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "spike_meerk40t_mock_mopa.py" +FIXTURE = Path(__file__).parent / "fixtures" / "meerk40t-mock-mopa-trace.json" +SPEC = importlib.util.spec_from_file_location("spike_meerk40t_mock_mopa", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class MockSpikeSafetyTests(unittest.TestCase): + def test_golden_trace_records_pin_mock_and_mopa_values(self): + result = json.loads(FIXTURE.read_text(encoding="utf-8")) + trace = "\n".join(result["trace"]) + self.assertEqual(MODULE.PINNED_COMMIT, result["commit"]) + self.assertEqual("MockConnection", result["transport"]) + self.assertEqual("trapped; none attempted", result["usb_access"]) + self.assertIn("801b:01f4", trace) + self.assertIn("8026:00c8", trace) + self.assertEqual(4, trace.count("listMarkTo")) + + def test_usb_trap_rejects_enumeration(self): + with patch.dict(sys.modules, clear=False): + MODULE._install_usb_trap() + import usb.core + + with self.assertRaisesRegex(AssertionError, "real USB access"): + usb.core.find(idVendor=0x9588, idProduct=0x9899) + + def test_rejects_unpinned_checkout(self): + with patch.object(MODULE, "_commit_of", return_value="wrong"): + with self.assertRaisesRegex(RuntimeError, "expected MeerK40t"): + MODULE.run(Path("unused")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rayforge_addon_spike.py b/tests/test_rayforge_addon_spike.py new file mode 100644 index 0000000..7f9ab46 --- /dev/null +++ b/tests/test_rayforge_addon_spike.py @@ -0,0 +1,91 @@ +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +ADDON = ROOT / "spikes" / "rayforge-jcz-no-device-addon" +FIXTURE = ROOT / "tests" / "fixtures" / "rayforge-addon-spike.json" +EQUIVALENCE_FIXTURE = ( + ROOT / "tests" / "fixtures" / "rayforge-jcz-equivalence.json" +) +NEGATIVE_FIXTURE = ( + ROOT / "tests" / "fixtures" / "rayforge-jcz-negative-cases.json" +) + + +class RayforgeAddonSpikeTests(unittest.TestCase): + def test_golden_result_is_hardware_free_binary_driver(self): + result = json.loads(FIXTURE.read_text(encoding="utf-8")) + self.assertFalse(result["uses_gcode"]) + self.assertIsNone(result["discovery"]) + self.assertIsNone(result["transport"]) + self.assertEqual( + ["load", "disable", "enable", "reload", "disable"], + result["lifecycle"], + ) + self.assertEqual(23, result["rayforge_api_version"]) + + def test_addon_contains_no_hardware_transport_import(self): + worker = (ADDON / "pirate_ray_jcz" / "worker.py").read_text( + encoding="utf-8" + ) + compact = worker.lower() + for forbidden in ("import usb", "import serial", "import socket"): + self.assertNotIn(forbidden, compact) + self.assertIn("discovery = none", compact) + self.assertIn("uses_gcode = false", compact) + + def test_manifest_and_golden_package_agree(self): + result = json.loads(FIXTURE.read_text(encoding="utf-8")) + actual = sorted( + path.relative_to(ADDON).as_posix() + for path in ADDON.rglob("*") + if path.is_file() and "__pycache__" not in path.parts + ) + self.assertEqual(result["packaged_files"], actual) + + def test_semantic_equivalence_fixture_has_no_failed_mapping(self): + result = json.loads(EQUIVALENCE_FIXTURE.read_text(encoding="utf-8")) + self.assertIsNone(result["transport"]) + self.assertEqual(13, result["rayforge_ops_count"]) + self.assertTrue(all(result["balormk_equivalence"].values())) + commands = [item["command"] for item in result["semantic_trace"]] + self.assertEqual(4, commands.count("mark")) + self.assertIn("pulse_width", commands) + self.assertIn("laser_on_point", commands) + + def test_packet_records_match_pinned_balor_values(self): + result = json.loads(EQUIVALENCE_FIXTURE.read_text(encoding="utf-8")) + records = result["command_records_hex"] + self.assertTrue(all(len(record) == 24 for record in records)) + self.assertEqual( + [ + "128000080000000000000000", + "0c8094000000000000000000", + "0680a7040000000000000000", + "1b80f4010000000000000000", + "2680c8000000000000000000", + "0180007000700000a0160000", + "058000710070000000010000", + "058000710071000000010000", + "058000700071000000010000", + "058000700070000000010000", + "038064000000000000000000", + "028000000000000000000000", + ], + records, + ) + + def test_negative_cases_all_fail_closed_without_transport(self): + result = json.loads(NEGATIVE_FIXTURE.read_text(encoding="utf-8")) + self.assertTrue(result["all_failed_closed"]) + self.assertIsNone(result["transport"]) + self.assertEqual(6, len(result["cases"])) + self.assertEqual( + {"rejected"}, {case["result"] for case in result["cases"]} + ) + + +if __name__ == "__main__": + unittest.main()