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",
|
||||
"sha256": "89af6a0409542706ccb6cf406a288e7ce65d4f4b3a463930674bb1569225cbd1",
|
||||
"bytes": 2298
|
||||
"sha256": "3275a59b6f06b7690e1923669a19088b42930310bdc725b7004f51f36ad61bb8",
|
||||
"bytes": 2182
|
||||
},
|
||||
{
|
||||
"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
|
||||
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,
|
||||
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
|
||||
|
|
|
|||
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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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:
|
|||
</resources>
|
||||
""",
|
||||
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)
|
||||
|
||||
|
|
|
|||
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