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
|
||||
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
|
||||
`tests/fixtures/rayforge-lightburn-device-import.json`. Two runs produced the
|
||||
same SHA-256 digest,
|
||||
`3c26f43be6b06e9b17a5decbe4ba7bb633eb1b8ac7bb0361449d7498a3b9279c`.
|
||||
`94f0d17837471399334a82c84558985d35da0f1a8eee6a176eb02d57ae3aeb18`.
|
||||
|
||||
## 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
|
||||
unpublished connection serialization.
|
||||
|
||||
This spike characterizes the current importer; it does not implement a driver
|
||||
mapping. A future `JCZFiber` mapping must target the separately tested
|
||||
no-transport addon driver and require an exact compatible profile. It must not
|
||||
silently map `JCZFiber` to a G-code or Ruida driver.
|
||||
This spike characterizes the current importer and proves the smallest mapping
|
||||
at the external-addon boundary. It does not change Rayforge core or silently
|
||||
map `JCZFiber` to a G-code or Ruida driver.
|
||||
|
||||
## Reproduce
|
||||
|
||||
|
|
@ -44,10 +52,8 @@ python scripts/spike_rayforge_lightburn_device_import.py C:\path\to\rayforge
|
|||
|
||||
## Next seam
|
||||
|
||||
The smallest evidence-backed extension is to recognize `JCZFiber` as a
|
||||
distinct, fail-closed profile kind, preserve its raw settings with explicit
|
||||
units/provenance, and associate it only with the external no-device JCZ driver.
|
||||
Cut-list recipes and project-layer `QPulseWidth` mapping remain gated until a
|
||||
known-working `.lbrn2` fixture establishes project serialization.
|
||||
The next extension is offline controller-list buffering and explicit safe
|
||||
state ordering. 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
- A simulator test precedes every equivalent hardware behavior.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ def _commit_of(source: Path) -> str:
|
|||
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)
|
||||
if commit != RAYFORGE_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)
|
||||
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"]
|
||||
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),
|
||||
"home_on_start": profile.machine_config.home_on_start,
|
||||
},
|
||||
"addon_profile_seam": seam,
|
||||
"gap_matrix": {
|
||||
"jczfiber_driver_mapped": profile.machine_config.driver is not None,
|
||||
"require_frame_before_start_mapped": False,
|
||||
|
|
@ -92,6 +98,11 @@ 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(
|
||||
"--fixture",
|
||||
type=Path,
|
||||
|
|
@ -99,7 +110,9 @@ def main() -> None:
|
|||
)
|
||||
parser.add_argument("--output", type=Path)
|
||||
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"
|
||||
if args.output:
|
||||
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": [
|
||||
"pirate_ray_jcz/__init__.py",
|
||||
"pirate_ray_jcz/profile_import.py",
|
||||
"pirate_ray_jcz/worker.py",
|
||||
"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",
|
||||
"fixture": "synthetic-jcz-device.lbdev",
|
||||
"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",
|
||||
"SyntheticProfileId": "synthetic-jpt-mopa-110-v1",
|
||||
"DeviceList": [
|
||||
{
|
||||
"DisplayName": "Synthetic JCZ MOPA",
|
||||
|
|
|
|||
|
|
@ -27,10 +27,11 @@ class RayforgeAddonSpikeTests(unittest.TestCase):
|
|||
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"
|
||||
source = "\n".join(
|
||||
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"):
|
||||
self.assertNotIn(forbidden, compact)
|
||||
self.assertIn("discovery = none", compact)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import json
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
RESULT = ROOT / "tests" / "fixtures" / "rayforge-lightburn-device-import.json"
|
||||
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):
|
||||
|
|
@ -39,6 +48,44 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
|
|||
self.assertFalse(gaps["q_pulse_width_mapped"])
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue