201 lines
6 KiB
Python
201 lines
6 KiB
Python
"""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()
|