Add offline Rayforge and BalorMK validation spikes
This commit is contained in:
parent
b8fb3ee658
commit
4742099be8
23 changed files with 1535 additions and 10 deletions
|
|
@ -141,8 +141,8 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "ios/AppIcon.appiconset/Contents.json",
|
"path": "ios/AppIcon.appiconset/Contents.json",
|
||||||
"sha256": "89af6a0409542706ccb6cf406a288e7ce65d4f4b3a463930674bb1569225cbd1",
|
"sha256": "3275a59b6f06b7690e1923669a19088b42930310bdc725b7004f51f36ad61bb8",
|
||||||
"bytes": 2298
|
"bytes": 2182
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "ios/AppIcon.appiconset/icon-1024.png",
|
"path": "ios/AppIcon.appiconset/icon-1024.png",
|
||||||
|
|
|
||||||
|
|
@ -85,3 +85,23 @@ workflow. Record evidence before choosing a standalone app.
|
||||||
See [Open-source landscape](../research/open-source-landscape.md) for the
|
See [Open-source landscape](../research/open-source-landscape.md) for the
|
||||||
candidate matrix, inspected commits, risks and detailed spike plan.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
67
docs/research/meerk40t-mock-mopa-spike.md
Normal file
67
docs/research/meerk40t-mock-mopa-spike.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -158,8 +158,9 @@ Source inspection found particularly relevant implementation details:
|
||||||
- imported LightBurn cut settings are mapped into Rayforge workflows,
|
- imported LightBurn cut settings are mapped into Rayforge workflows,
|
||||||
including speed, power, passes, offsets, raster interval/angle and dither;
|
including speed, power, passes, offsets, raster interval/angle and dither;
|
||||||
- `.lbdev` device profiles have a separate importer;
|
- `.lbdev` device profiles have a separate importer;
|
||||||
- laser steps and recipes serialize `frequency` in Hz and `pulse_width` in
|
- laser steps and recipes serialize `frequency` in Hz; the laser-step UI
|
||||||
microseconds;
|
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;
|
- the neutral `Ops` command set contains frequency and pulse-width commands;
|
||||||
- binary rather than G-code drivers are supported through an `OpsEncoder`;
|
- binary rather than G-code drivers are supported through an `OpsEncoder`;
|
||||||
- driver state, cancel/hold, discovery, machine profiles, simulator and
|
- driver state, cancel/hold, discovery, machine profiles, simulator and
|
||||||
|
|
|
||||||
82
docs/research/rayforge-addon-spike.md
Normal file
82
docs/research/rayforge-addon-spike.md
Normal file
|
|
@ -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.
|
||||||
64
docs/research/rayforge-jcz-equivalence-spike.md
Normal file
64
docs/research/rayforge-jcz-equivalence-spike.md
Normal file
|
|
@ -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.
|
||||||
37
docs/research/rayforge-jcz-negative-cases.md
Normal file
37
docs/research/rayforge-jcz-negative-cases.md
Normal file
|
|
@ -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.
|
||||||
47
docs/research/rayforge-jcz-packet-spike.md
Normal file
47
docs/research/rayforge-jcz-packet-spike.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Product and research roadmap
|
# Product and research roadmap
|
||||||
|
|
||||||
Last updated: 2026-10-17
|
Last updated: 2026-10-18
|
||||||
|
|
||||||
## Destination
|
## Destination
|
||||||
|
|
||||||
|
|
@ -104,6 +104,41 @@ The first work is entirely offline:
|
||||||
- identify only the missing profile, validation, diagnostic and safety
|
- identify only the missing profile, validation, diagnostic and safety
|
||||||
concepts instead of designing replacements for existing ones.
|
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
|
The architecture decision remains proposed until these spikes produce
|
||||||
evidence. An independent app requires a documented gap analysis showing why
|
evidence. An independent app requires a documented gap analysis showing why
|
||||||
neither existing platform can be extended responsibly.
|
neither existing platform can be extended responsibly.
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,9 @@ def generate_ios(source: Image.Image) -> None:
|
||||||
contents = {"images": images, "info": {"author": "xcode", "version": 1}}
|
contents = {"images": images, "info": {"author": "xcode", "version": 1}}
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
(directory / "Contents.json").write_text(
|
(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 = OUTPUT / "android" / "mipmap-anydpi-v26"
|
||||||
anydpi.mkdir(parents=True, exist_ok=True)
|
anydpi.mkdir(parents=True, exist_ok=True)
|
||||||
(anydpi / "ic_launcher.xml").write_text(adaptive_xml, encoding="utf-8")
|
(anydpi / "ic_launcher.xml").write_text(
|
||||||
(anydpi / "ic_launcher_round.xml").write_text(adaptive_xml, encoding="utf-8")
|
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 = OUTPUT / "android" / "values"
|
||||||
values.mkdir(parents=True, exist_ok=True)
|
values.mkdir(parents=True, exist_ok=True)
|
||||||
(values / "ic_launcher_background.xml").write_text(
|
(values / "ic_launcher_background.xml").write_text(
|
||||||
|
|
@ -359,6 +365,7 @@ def generate_android(source: Image.Image) -> None:
|
||||||
</resources>
|
</resources>
|
||||||
""",
|
""",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -434,7 +441,9 @@ def generate() -> None:
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
}
|
}
|
||||||
(web / "site.webmanifest").write_text(
|
(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)
|
generate_windows(source)
|
||||||
|
|
@ -466,7 +475,9 @@ def generate() -> None:
|
||||||
"files": files,
|
"files": files,
|
||||||
}
|
}
|
||||||
(OUTPUT / "manifest.json").write_text(
|
(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)
|
write_branding_readme(files)
|
||||||
|
|
||||||
|
|
|
||||||
201
scripts/spike_meerk40t_mock_mopa.py
Normal file
201
scripts/spike_meerk40t_mock_mopa.py
Normal file
|
|
@ -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()
|
||||||
143
scripts/spike_rayforge_addon.py
Normal file
143
scripts/spike_rayforge_addon.py
Normal file
|
|
@ -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()
|
||||||
152
scripts/spike_rayforge_jcz_equivalence.py
Normal file
152
scripts/spike_rayforge_jcz_equivalence.py
Normal file
|
|
@ -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()
|
||||||
127
scripts/spike_rayforge_jcz_negative_cases.py
Normal file
127
scripts/spike_rayforge_jcz_negative_cases.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""The Pirate Ray JCZ offline Rayforge spike."""
|
||||||
245
spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py
Normal file
245
spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py
Normal file
|
|
@ -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()
|
||||||
12
spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml
Normal file
12
spikes/rayforge-jcz-no-device-addon/rayforge-addon.yaml
Normal file
|
|
@ -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
|
||||||
33
tests/fixtures/meerk40t-mock-mopa-trace.json
vendored
Normal file
33
tests/fixtures/meerk40t-mock-mopa-trace.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
24
tests/fixtures/rayforge-addon-spike.json
vendored
Normal file
24
tests/fixtures/rayforge-addon-spike.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
50
tests/fixtures/rayforge-jcz-equivalence.json
vendored
Normal file
50
tests/fixtures/rayforge-jcz-equivalence.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
38
tests/fixtures/rayforge-jcz-negative-cases.json
vendored
Normal file
38
tests/fixtures/rayforge-jcz-negative-cases.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
44
tests/test_meerk40t_mock_spike.py
Normal file
44
tests/test_meerk40t_mock_spike.py
Normal file
|
|
@ -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()
|
||||||
91
tests/test_rayforge_addon_spike.py
Normal file
91
tests/test_rayforge_addon_spike.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Reference in a new issue