Add fail-closed JCZ profile import seam
This commit is contained in:
parent
131ca4b737
commit
47d83684ee
9 changed files with 241 additions and 15 deletions
|
|
@ -16,10 +16,19 @@ The imported `DeviceProfile` also has no representation of the observed
|
||||||
`QPulseWidth` fields. The import is therefore explicitly classified as unsafe
|
`QPulseWidth` fields. The import is therefore explicitly classified as unsafe
|
||||||
for JCZ use rather than receiving guessed defaults.
|
for JCZ use rather than receiving guessed defaults.
|
||||||
|
|
||||||
|
The external addon now supplies the narrow profile seam missing from Rayforge
|
||||||
|
core. It recognizes only the explicit public synthetic profile identity,
|
||||||
|
requires `JCZFiber`, positive work-area dimensions and
|
||||||
|
`RequireFrameBeforeStart: true`, and associates the result only with
|
||||||
|
`PirateRayJCZNoDeviceDriver`. Frequency and `QPulseWidth` remain raw
|
||||||
|
observations with unverified serialized-unit conversion. Encoding remains
|
||||||
|
blocked because the physical profile, lens/correction match, project
|
||||||
|
serialization and machine-specific limits are not yet verified.
|
||||||
|
|
||||||
The checked result is
|
The checked result is
|
||||||
`tests/fixtures/rayforge-lightburn-device-import.json`. Two runs produced the
|
`tests/fixtures/rayforge-lightburn-device-import.json`. Two runs produced the
|
||||||
same SHA-256 digest,
|
same SHA-256 digest,
|
||||||
`3c26f43be6b06e9b17a5decbe4ba7bb633eb1b8ac7bb0361449d7498a3b9279c`.
|
`94f0d17837471399334a82c84558985d35da0f1a8eee6a176eb02d57ae3aeb18`.
|
||||||
|
|
||||||
## Evidence boundary
|
## Evidence boundary
|
||||||
|
|
||||||
|
|
@ -29,10 +38,9 @@ ranges, not a copy of a private export and not certified machine limits or
|
||||||
material settings. It deliberately omits private GUIDs, source paths and
|
material settings. It deliberately omits private GUIDs, source paths and
|
||||||
unpublished connection serialization.
|
unpublished connection serialization.
|
||||||
|
|
||||||
This spike characterizes the current importer; it does not implement a driver
|
This spike characterizes the current importer and proves the smallest mapping
|
||||||
mapping. A future `JCZFiber` mapping must target the separately tested
|
at the external-addon boundary. It does not change Rayforge core or silently
|
||||||
no-transport addon driver and require an exact compatible profile. It must not
|
map `JCZFiber` to a G-code or Ruida driver.
|
||||||
silently map `JCZFiber` to a G-code or Ruida driver.
|
|
||||||
|
|
||||||
## Reproduce
|
## Reproduce
|
||||||
|
|
||||||
|
|
@ -44,10 +52,8 @@ python scripts/spike_rayforge_lightburn_device_import.py C:\path\to\rayforge
|
||||||
|
|
||||||
## Next seam
|
## Next seam
|
||||||
|
|
||||||
The smallest evidence-backed extension is to recognize `JCZFiber` as a
|
The next extension is offline controller-list buffering and explicit safe
|
||||||
distinct, fail-closed profile kind, preserve its raw settings with explicit
|
state ordering. Cut-list recipes and project-layer `QPulseWidth` mapping remain
|
||||||
units/provenance, and associate it only with the external no-device JCZ driver.
|
gated until a known-working `.lbrn2` fixture establishes project serialization.
|
||||||
Cut-list recipes and project-layer `QPulseWidth` mapping remain gated until a
|
|
||||||
known-working `.lbrn2` fixture establishes project serialization.
|
|
||||||
|
|
||||||
No USB enumeration, controller command, motion, framing or emission occurred.
|
No USB enumeration, controller command, motion, framing or emission occurred.
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,12 @@ Rayforge preserves the common name and work-area fields, but maps no
|
||||||
`JCZFiber` driver and drops the JCZ-specific safety and MOPA fields from its
|
`JCZFiber` driver and drops the JCZ-specific safety and MOPA fields from its
|
||||||
profile model. The fixture and regression test remain transport-free.
|
profile model. The fixture and regression test remain transport-free.
|
||||||
|
|
||||||
|
Item 3 now has a minimal external-addon seam: it recognizes only the synthetic
|
||||||
|
`JCZFiber` fixture with explicit provenance and frame-before-start, binds it to
|
||||||
|
the no-device JCZ driver, preserves process values as raw observations with
|
||||||
|
unit uncertainty, and keeps encoding disabled until the remaining profile and
|
||||||
|
project evidence exists.
|
||||||
|
|
||||||
## Decision rules
|
## Decision rules
|
||||||
|
|
||||||
- A simulator test precedes every equivalent hardware behavior.
|
- A simulator test precedes every equivalent hardware behavior.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ def _commit_of(source: Path) -> str:
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def run(source: Path, fixture: Path) -> dict[str, object]:
|
def run(source: Path, fixture: Path, addon: Path) -> dict[str, object]:
|
||||||
commit = _commit_of(source)
|
commit = _commit_of(source)
|
||||||
if commit != RAYFORGE_COMMIT:
|
if commit != RAYFORGE_COMMIT:
|
||||||
raise RuntimeError(f"expected Rayforge {RAYFORGE_COMMIT}, found {commit}")
|
raise RuntimeError(f"expected Rayforge {RAYFORGE_COMMIT}, found {commit}")
|
||||||
|
|
@ -39,6 +39,11 @@ def run(source: Path, fixture: Path) -> dict[str, object]:
|
||||||
|
|
||||||
raw = parse_lbdev(fixture)
|
raw = parse_lbdev(fixture)
|
||||||
profile, summary = convert_to_profile(fixture)
|
profile, summary = convert_to_profile(fixture)
|
||||||
|
document = json.loads(fixture.read_text(encoding="utf-8"))
|
||||||
|
sys.path.insert(0, str(addon))
|
||||||
|
from pirate_ray_jcz.profile_import import import_lightburn_jcz_profile
|
||||||
|
|
||||||
|
seam = import_lightburn_jcz_profile(document)
|
||||||
settings = raw["Settings"]
|
settings = raw["Settings"]
|
||||||
cut_entry = raw["CutList"][0]
|
cut_entry = raw["CutList"][0]
|
||||||
|
|
||||||
|
|
@ -72,6 +77,7 @@ def run(source: Path, fixture: Path) -> dict[str, object]:
|
||||||
"work_area_mm": list(profile.machine_config.axis_extents),
|
"work_area_mm": list(profile.machine_config.axis_extents),
|
||||||
"home_on_start": profile.machine_config.home_on_start,
|
"home_on_start": profile.machine_config.home_on_start,
|
||||||
},
|
},
|
||||||
|
"addon_profile_seam": seam,
|
||||||
"gap_matrix": {
|
"gap_matrix": {
|
||||||
"jczfiber_driver_mapped": profile.machine_config.driver is not None,
|
"jczfiber_driver_mapped": profile.machine_config.driver is not None,
|
||||||
"require_frame_before_start_mapped": False,
|
"require_frame_before_start_mapped": False,
|
||||||
|
|
@ -92,6 +98,11 @@ def main() -> None:
|
||||||
root = Path(__file__).parents[1]
|
root = Path(__file__).parents[1]
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("source", type=Path, help="pinned Rayforge checkout")
|
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(
|
parser.add_argument(
|
||||||
"--fixture",
|
"--fixture",
|
||||||
type=Path,
|
type=Path,
|
||||||
|
|
@ -99,7 +110,9 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
parser.add_argument("--output", type=Path)
|
parser.add_argument("--output", type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
result = run(args.source.resolve(), args.fixture.resolve())
|
result = run(
|
||||||
|
args.source.resolve(), args.fixture.resolve(), args.addon.resolve()
|
||||||
|
)
|
||||||
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
||||||
if args.output:
|
if args.output:
|
||||||
args.output.write_text(rendered, encoding="utf-8", newline="\n")
|
args.output.write_text(rendered, encoding="utf-8", newline="\n")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Fail-closed LightBurn JCZ profile seam with no hardware capability."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DRIVER_NAME = "PirateRayJCZNoDeviceDriver"
|
||||||
|
SYNTHETIC_PROFILE_ID = "synthetic-jpt-mopa-110-v1"
|
||||||
|
|
||||||
|
|
||||||
|
class JCZProfileImportError(ValueError):
|
||||||
|
"""Raised when a LightBurn profile cannot be recognized safely."""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_positive_number(value: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(value, (int, float))
|
||||||
|
and not isinstance(value, bool)
|
||||||
|
and value > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Recognize the public synthetic profile without enabling encoding.
|
||||||
|
|
||||||
|
The returned process values remain serialized raw observations. This
|
||||||
|
function does not infer LightBurn project fields, units, machine limits,
|
||||||
|
lens correction, controller transport, or hardware compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if document.get("FixtureProvenance") != (
|
||||||
|
"synthetic public data; not a machine export"
|
||||||
|
):
|
||||||
|
raise JCZProfileImportError("profile provenance is absent or unsupported")
|
||||||
|
if document.get("SyntheticProfileId") != SYNTHETIC_PROFILE_ID:
|
||||||
|
raise JCZProfileImportError("synthetic profile identity mismatch")
|
||||||
|
|
||||||
|
devices = document.get("DeviceList")
|
||||||
|
if not isinstance(devices, list) or len(devices) != 1:
|
||||||
|
raise JCZProfileImportError("exactly one LightBurn device is required")
|
||||||
|
device = devices[0]
|
||||||
|
if not isinstance(device, dict) or device.get("Type") != "JCZFiber":
|
||||||
|
raise JCZProfileImportError("LightBurn device is not JCZFiber")
|
||||||
|
|
||||||
|
width = device.get("Width")
|
||||||
|
height = device.get("Height")
|
||||||
|
if not all(_is_positive_number(value) for value in (width, height)):
|
||||||
|
raise JCZProfileImportError("positive work-area dimensions are required")
|
||||||
|
|
||||||
|
settings = device.get("Settings")
|
||||||
|
if not isinstance(settings, dict) or settings.get(
|
||||||
|
"RequireFrameBeforeStart"
|
||||||
|
) is not True:
|
||||||
|
raise JCZProfileImportError("RequireFrameBeforeStart must be explicitly true")
|
||||||
|
|
||||||
|
cut_list = device.get("CutList")
|
||||||
|
if not isinstance(cut_list, list) or not cut_list:
|
||||||
|
raise JCZProfileImportError("at least one observed cut-list entry is required")
|
||||||
|
|
||||||
|
preserved_entries = []
|
||||||
|
for entry in cut_list:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise JCZProfileImportError("cut-list entry must be an object")
|
||||||
|
frequency = entry.get("frequency")
|
||||||
|
pulse_width = entry.get("QPulseWidth")
|
||||||
|
if not all(
|
||||||
|
_is_positive_number(value) for value in (frequency, pulse_width)
|
||||||
|
):
|
||||||
|
raise JCZProfileImportError(
|
||||||
|
"frequency and QPulseWidth must be positive raw values"
|
||||||
|
)
|
||||||
|
preserved_entries.append(
|
||||||
|
{
|
||||||
|
"name": entry.get("Name", ""),
|
||||||
|
"frequency": {
|
||||||
|
"raw_value": frequency,
|
||||||
|
"unit_status": "serialized-unit-conversion-unverified",
|
||||||
|
},
|
||||||
|
"q_pulse_width": {
|
||||||
|
"raw_value": pulse_width,
|
||||||
|
"ui_unit_contract": "nanoseconds",
|
||||||
|
"serialized_unit_conversion": "unverified",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"profile_id": SYNTHETIC_PROFILE_ID,
|
||||||
|
"driver": DRIVER_NAME,
|
||||||
|
"transport": None,
|
||||||
|
"source": {
|
||||||
|
"format": "LightBurn synthetic .lbdev",
|
||||||
|
"provenance": document["FixtureProvenance"],
|
||||||
|
"backend": "JCZFiber",
|
||||||
|
},
|
||||||
|
"work_area_mm": [float(width), float(height)],
|
||||||
|
"safety": {"require_frame_before_start": True},
|
||||||
|
"process_metadata": {
|
||||||
|
"status": "preserved-raw-not-mapped",
|
||||||
|
"cut_list": preserved_entries,
|
||||||
|
},
|
||||||
|
"encoding_allowed": False,
|
||||||
|
"blockers": [
|
||||||
|
"synthetic profile is not physical machine identity",
|
||||||
|
"lens and correction profile are unverified",
|
||||||
|
"LightBurn project QPulseWidth serialization is unverified",
|
||||||
|
"machine-specific parameter limits are unverified",
|
||||||
|
],
|
||||||
|
}
|
||||||
1
tests/fixtures/rayforge-addon-spike.json
vendored
1
tests/fixtures/rayforge-addon-spike.json
vendored
|
|
@ -17,6 +17,7 @@
|
||||||
],
|
],
|
||||||
"packaged_files": [
|
"packaged_files": [
|
||||||
"pirate_ray_jcz/__init__.py",
|
"pirate_ray_jcz/__init__.py",
|
||||||
|
"pirate_ray_jcz/profile_import.py",
|
||||||
"pirate_ray_jcz/worker.py",
|
"pirate_ray_jcz/worker.py",
|
||||||
"rayforge-addon.yaml"
|
"rayforge-addon.yaml"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,45 @@
|
||||||
{
|
{
|
||||||
|
"addon_profile_seam": {
|
||||||
|
"blockers": [
|
||||||
|
"synthetic profile is not physical machine identity",
|
||||||
|
"lens and correction profile are unverified",
|
||||||
|
"LightBurn project QPulseWidth serialization is unverified",
|
||||||
|
"machine-specific parameter limits are unverified"
|
||||||
|
],
|
||||||
|
"driver": "PirateRayJCZNoDeviceDriver",
|
||||||
|
"encoding_allowed": false,
|
||||||
|
"process_metadata": {
|
||||||
|
"cut_list": [
|
||||||
|
{
|
||||||
|
"frequency": {
|
||||||
|
"raw_value": 40000,
|
||||||
|
"unit_status": "serialized-unit-conversion-unverified"
|
||||||
|
},
|
||||||
|
"name": "Synthetic MOPA entry",
|
||||||
|
"q_pulse_width": {
|
||||||
|
"raw_value": 100,
|
||||||
|
"serialized_unit_conversion": "unverified",
|
||||||
|
"ui_unit_contract": "nanoseconds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "preserved-raw-not-mapped"
|
||||||
|
},
|
||||||
|
"profile_id": "synthetic-jpt-mopa-110-v1",
|
||||||
|
"safety": {
|
||||||
|
"require_frame_before_start": true
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"backend": "JCZFiber",
|
||||||
|
"format": "LightBurn synthetic .lbdev",
|
||||||
|
"provenance": "synthetic public data; not a machine export"
|
||||||
|
},
|
||||||
|
"transport": null,
|
||||||
|
"work_area_mm": [
|
||||||
|
110.0,
|
||||||
|
110.0
|
||||||
|
]
|
||||||
|
},
|
||||||
"conclusion": "name and work area survive, but JCZFiber has no driver mapping; RequireFrameBeforeStart, CutList, frequency and QPulseWidth are not represented in the imported Rayforge profile",
|
"conclusion": "name and work area survive, but JCZFiber has no driver mapping; RequireFrameBeforeStart, CutList, frequency and QPulseWidth are not represented in the imported Rayforge profile",
|
||||||
"fixture": "synthetic-jcz-device.lbdev",
|
"fixture": "synthetic-jcz-device.lbdev",
|
||||||
"fixture_provenance": "synthetic public data",
|
"fixture_provenance": "synthetic public data",
|
||||||
|
|
|
||||||
1
tests/fixtures/synthetic-jcz-device.lbdev
vendored
1
tests/fixtures/synthetic-jcz-device.lbdev
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"FixtureProvenance": "synthetic public data; not a machine export",
|
"FixtureProvenance": "synthetic public data; not a machine export",
|
||||||
|
"SyntheticProfileId": "synthetic-jpt-mopa-110-v1",
|
||||||
"DeviceList": [
|
"DeviceList": [
|
||||||
{
|
{
|
||||||
"DisplayName": "Synthetic JCZ MOPA",
|
"DisplayName": "Synthetic JCZ MOPA",
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,11 @@ class RayforgeAddonSpikeTests(unittest.TestCase):
|
||||||
self.assertEqual(23, result["rayforge_api_version"])
|
self.assertEqual(23, result["rayforge_api_version"])
|
||||||
|
|
||||||
def test_addon_contains_no_hardware_transport_import(self):
|
def test_addon_contains_no_hardware_transport_import(self):
|
||||||
worker = (ADDON / "pirate_ray_jcz" / "worker.py").read_text(
|
source = "\n".join(
|
||||||
encoding="utf-8"
|
path.read_text(encoding="utf-8")
|
||||||
|
for path in sorted((ADDON / "pirate_ray_jcz").glob("*.py"))
|
||||||
)
|
)
|
||||||
compact = worker.lower()
|
compact = source.lower()
|
||||||
for forbidden in ("import usb", "import serial", "import socket"):
|
for forbidden in ("import usb", "import serial", "import socket"):
|
||||||
self.assertNotIn(forbidden, compact)
|
self.assertNotIn(forbidden, compact)
|
||||||
self.assertIn("discovery = none", compact)
|
self.assertIn("discovery = none", compact)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).parents[1]
|
ROOT = Path(__file__).parents[1]
|
||||||
RESULT = ROOT / "tests" / "fixtures" / "rayforge-lightburn-device-import.json"
|
RESULT = ROOT / "tests" / "fixtures" / "rayforge-lightburn-device-import.json"
|
||||||
SOURCE = ROOT / "tests" / "fixtures" / "synthetic-jcz-device.lbdev"
|
SOURCE = ROOT / "tests" / "fixtures" / "synthetic-jcz-device.lbdev"
|
||||||
|
ADDON = ROOT / "spikes" / "rayforge-jcz-no-device-addon"
|
||||||
|
sys.path.insert(0, str(ADDON))
|
||||||
|
|
||||||
|
from pirate_ray_jcz.profile_import import ( # noqa: E402
|
||||||
|
JCZProfileImportError,
|
||||||
|
import_lightburn_jcz_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
|
class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
|
||||||
|
|
@ -39,6 +48,44 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
|
||||||
self.assertFalse(gaps["q_pulse_width_mapped"])
|
self.assertFalse(gaps["q_pulse_width_mapped"])
|
||||||
self.assertFalse(gaps["safe_for_jcz_use"])
|
self.assertFalse(gaps["safe_for_jcz_use"])
|
||||||
|
|
||||||
|
def test_addon_seam_preserves_raw_values_but_blocks_encoding(self):
|
||||||
|
result = json.loads(RESULT.read_text(encoding="utf-8"))
|
||||||
|
seam = result["addon_profile_seam"]
|
||||||
|
self.assertEqual("PirateRayJCZNoDeviceDriver", seam["driver"])
|
||||||
|
self.assertIsNone(seam["transport"])
|
||||||
|
self.assertFalse(seam["encoding_allowed"])
|
||||||
|
self.assertEqual("preserved-raw-not-mapped", seam["process_metadata"]["status"])
|
||||||
|
entry = seam["process_metadata"]["cut_list"][0]
|
||||||
|
self.assertEqual(40000, entry["frequency"]["raw_value"])
|
||||||
|
self.assertEqual(100, entry["q_pulse_width"]["raw_value"])
|
||||||
|
self.assertEqual("unverified", entry["q_pulse_width"]["serialized_unit_conversion"])
|
||||||
|
|
||||||
|
def test_addon_seam_rejects_wrong_backend_and_missing_frame_gate(self):
|
||||||
|
source = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||||
|
wrong_backend = deepcopy(source)
|
||||||
|
wrong_backend["DeviceList"][0]["Type"] = "Ruida"
|
||||||
|
with self.assertRaises(JCZProfileImportError):
|
||||||
|
import_lightburn_jcz_profile(wrong_backend)
|
||||||
|
|
||||||
|
no_frame_gate = deepcopy(source)
|
||||||
|
no_frame_gate["DeviceList"][0]["Settings"][
|
||||||
|
"RequireFrameBeforeStart"
|
||||||
|
] = False
|
||||||
|
with self.assertRaises(JCZProfileImportError):
|
||||||
|
import_lightburn_jcz_profile(no_frame_gate)
|
||||||
|
|
||||||
|
wrong_identity = deepcopy(source)
|
||||||
|
wrong_identity["SyntheticProfileId"] = "some-other-profile"
|
||||||
|
with self.assertRaises(JCZProfileImportError):
|
||||||
|
import_lightburn_jcz_profile(wrong_identity)
|
||||||
|
|
||||||
|
ambiguous_frequency = deepcopy(source)
|
||||||
|
ambiguous_frequency["DeviceList"][0]["CutList"][0][
|
||||||
|
"frequency"
|
||||||
|
] = True
|
||||||
|
with self.assertRaises(JCZProfileImportError):
|
||||||
|
import_lightburn_jcz_profile(ambiguous_frequency)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue