feat(e10): SIC e10 part marker client, pattern generator and eDNC simulator
Groundwork for replacing a Telesis pinstamp with a SIC Marking e10 while
keeping the existing eDNC/MarkTelesis operator workflow. MarkTelesis has a
hardcoded marker-type enum and eDNC is serial-only, so the plan is a
translator that impersonates a Telesis controller on a COM port. These are
the pieces that translator will be built from, all verified against a real
e10 (a P63, firmware "E10 P63 (20140924)").
scripts/e10-client.py
e10-V6 protocol client. Marking path and file transfer both use the BINARY
protocol: the text protocol uses space as its delimiter, so it cannot carry
a field cleared to a single space nor the space runs in real marked text,
and it has no checksum. Includes fault decoding against the manual's fig 1,
and a guard that refuses to overflow a variable's declared buffer.
scripts/make-e10-pattern.py
Builds marking files with DECLARED variables. The controller's own protocol
cannot create one: NEWFILE/INSERTTEXTLINE/SAVEFILE writes the reference but
leaves the variable count at zero, and SETVAR then fails. Template-based,
copying through byte-for-byte everything the format notes do not explain.
scripts/simulate-ednc.py
Parses a part program, substitutes tokens and emits the byte-exact frames
MarkTelesis would send, then translates them for the e10 and can drive one.
The wire format came from disassembling MarkTelesis.exe rather than from a
capture: frame builder at 0x401cf0, and the type letters at their call
sites. Note there is no station ID, unlike the Backup470 dialect.
scripts/fake-telesis.py
Answers as a Telesis controller so MarkTelesis will hold a conversation
long enough to be recorded, with switches to force NAK and timeout paths.
reference/e10/
52-file snapshot off the bench controller, used as the corpus for reversing
the MARK_V5.0 format and for testing without hardware. ConfigCtrl is
excluded and gitignored - it stores the supervisor password in cleartext.
GE part programs are excluded for the same reason.
Three hardware findings worth knowing:
- The controller TRUNCATES SILENTLY to a variable's declared buffer size
while SETVAR still answers ok, and GETVAR echoes the full value back so
it cannot detect this. Guarded client-side against the size in the file.
- It refuses to mark when a referenced variable is unset, which makes an
unset field fail loudly rather than marking the previous part's data.
- Setting variables does not modify the stored file, so backups will not
churn on every marked part.
This commit is contained in:
829
scripts/e10-client.py
Normal file
829
scripts/e10-client.py
Normal file
@@ -0,0 +1,829 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Talk to a SIC Marking e10-V6 marking controller over RS-232.
|
||||
|
||||
Written against NOTPROTOE10V6US-R08 (the e10-V6 communication protocol manual)
|
||||
and VERIFIED against a real controller on 2026-08-27 - a P63, firmware
|
||||
"E10 P63 (20140924)". Listing, reading and the machine query all work over the
|
||||
HOST port; a 52-file snapshot came off it in one command.
|
||||
|
||||
Two things the hardware taught us that the manual gets wrong:
|
||||
- File names are CASE-SENSITIVE and not always uppercase (see encode_name).
|
||||
- File kinds 1 and 7 exist and are readable, though the manual lists only
|
||||
0/2/4/5. Kind 1 is where the controller configuration lives, which is what
|
||||
makes a complete settings snapshot possible.
|
||||
|
||||
Still unverified against hardware: the marking path itself (LOAD FILE,
|
||||
FILE SET VAR, START MARKING). Those move a stylus, so they wait for a
|
||||
deliberate session rather than a probe.
|
||||
|
||||
WHY BINARY AND NOT TEXT
|
||||
The manual defines two protocols. The TEXT one is easier to read and is
|
||||
what SIC's own support quoted at us, but it cannot carry the values this
|
||||
integration needs:
|
||||
|
||||
1. Space is the TEXT protocol's delimiter - data is defined as "the data
|
||||
of the command ( without Space char)". The GE marker software blanks
|
||||
every field to a SINGLE SPACE before setting values ("TmcClrFlds: Put
|
||||
a SPACE in each field."). A value that IS a space is unrepresentable.
|
||||
2. Real GE part programs mark text containing runs of spaces, e.g.
|
||||
"S/N <SERIALNO> 4AM79". TEXT protocol handling of embedded and
|
||||
consecutive spaces is undefined by the manual.
|
||||
3. TEXT frames carry NO checksum of any kind. The Telesis protocol this
|
||||
replaces has a BCC on every frame in both directions.
|
||||
|
||||
BINARY FILE SET VAR ('7') carries the value as length-framed bytes, 1 to
|
||||
127 of them, so a lone space is legal and space runs survive. And the
|
||||
binary framing has an optional XOR checksum. Hence: binary for anything
|
||||
that reaches a part, text for bench poking only.
|
||||
|
||||
FRAMING (manual section I.3.a)
|
||||
without checksum: [STX][NUL][VER] <command>... [ETX]
|
||||
with checksum: [STX][VER] <command>... [ETX][XOR]
|
||||
|
||||
VER is '5' (0x35). The XOR covers every byte of the string INCLUDING STX
|
||||
and ETX. A NUL directly after STX is what disables checksum control, which
|
||||
is why every worked example in the manual starts 02 00 35.
|
||||
|
||||
command: [code][size hi][size lo][data] (size is big-endian)
|
||||
|
||||
The manual also defines a break-code command form for when you do not want
|
||||
to count the data. Not implemented - the length form is unambiguous and we
|
||||
always know our own lengths.
|
||||
|
||||
RESPONSES (manual section I.3.b)
|
||||
Whole-string errors arrive as one bare byte:
|
||||
BS checksum error HT bad string syntax NAK timeout
|
||||
Otherwise: [STX] <answer>... [ETX]
|
||||
where answer is [original code][size hi][size lo][data], and data is very
|
||||
often a single return code:
|
||||
ACK ok HT wrong data syntax
|
||||
BEL file not found LF variable not found
|
||||
|
||||
MARKING COMPLETION IS ASYNCHRONOUS AND TWO-STAGE (manual, START MARKING 'g')
|
||||
After the ACK for the start command:
|
||||
[EOT] last impact done
|
||||
[ENQ] head back at home position
|
||||
[NAK][status(3)] fault, and the error must then be reset
|
||||
DONE MEANS ENQ, NOT EOT. Calling it done at EOT reports a finished cycle
|
||||
while the head is still travelling, which at a bay means an operator
|
||||
reaching for a part that is still moving.
|
||||
|
||||
PAUSE LINES ARE NOT OURS TO ANSWER
|
||||
At a PAUSE line the controller sends 'P' and waits for either 'p' on the
|
||||
serial line or the physical Start button. This client NEVER sends 'p'. A
|
||||
pause exists so a human can reposition the part; answering it from software
|
||||
resumes marking on a part nobody has touched. wait_for_mark() surfaces the
|
||||
pause to the caller and keeps waiting.
|
||||
|
||||
DELETE IS DELIBERATELY ABSENT
|
||||
DEL_FILE ('D') exists and is documented. It is not implemented here, for
|
||||
the same reason Telesis TYPE 'R' was left out of TelesisMarker.psm1: a
|
||||
backup tool that can delete is a backup tool that can be made to delete.
|
||||
|
||||
LINK LAYER
|
||||
e10 HOST port is fixed at 9600-8-N-1 and cannot be reconfigured. Pins
|
||||
4/6/7/8 are NC, so there are no handshake lines and hardware flow control
|
||||
is impossible - the port is opened with flow control off and the manual's
|
||||
own backpressure rule applies instead: 500-byte controller buffer, do not
|
||||
push faster than it drains, 40000 bytes maximum per string.
|
||||
|
||||
Both ends are DTE (each calls pin 2 RX and pin 3 TX), so the cable must be
|
||||
a crossover: 3 to 2, 2 to 3, 5 to 5.
|
||||
|
||||
Stdlib only, termios - no pyserial on the bench box.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
|
||||
# framing
|
||||
STX = 0x02
|
||||
ETX = 0x03
|
||||
VERSION = 0x35 # '5', protocol version byte
|
||||
|
||||
# return codes and whole-string errors
|
||||
EOT = 0x04 # last impact marked
|
||||
ENQ = 0x05 # back at home position
|
||||
ACK = 0x06 # success
|
||||
BEL = 0x07 # file not found
|
||||
BS = 0x08 # checksum error (whole string)
|
||||
HT = 0x09 # bad syntax
|
||||
LF_ = 0x0A # variable not found
|
||||
NAK = 0x15 # timeout, or fault during marking
|
||||
PAUSE_ASK = 0x50 # 'P', controller waiting at a PAUSE line
|
||||
PAUSE_ANSWER = 0x70 # 'p'. deliberately never sent. see module docstring.
|
||||
|
||||
# command codes, manual section I.3.c
|
||||
CMD_LOAD_FILE = 0x63 # 'c'
|
||||
CMD_FILE_SET_VAR = 0x37 # '7'
|
||||
CMD_START_MARKING = 0x67 # 'g'
|
||||
CMD_RESET_ERROR = 0x45 # 'E'
|
||||
CMD_FILE_LIST = 0x4C # 'L'
|
||||
CMD_PC_TO_E10 = 0x47 # 'G'
|
||||
CMD_E10_TO_PC = 0x53 # 'S'
|
||||
CMD_GET_MACHINE = 0x81
|
||||
|
||||
# file kinds, manual FILE LIST
|
||||
KIND_ALL = 0
|
||||
|
||||
# 1 and 7 are UNDOCUMENTED - the manual lists only 0/2/4/5. Found on a real
|
||||
# controller: kind 1 holds ConfigCtrl (the controller configuration),
|
||||
# File-Select (the digital-input file selection map) and FolderDef; kind 7 held
|
||||
# SAVE_ACC_1, which looks like accessory or axis data. They pull fine over
|
||||
# E10_TO_PC, which is what makes a complete settings snapshot possible at all.
|
||||
KIND_CONFIG = 1
|
||||
KIND_MARKING = 2
|
||||
KIND_DOT_LOGO = 4
|
||||
KIND_SCRIBBLE_LOGO = 5
|
||||
KIND_ACCESSORY = 7
|
||||
|
||||
KIND_NAMES = {
|
||||
KIND_CONFIG: "config",
|
||||
KIND_MARKING: "marking",
|
||||
KIND_DOT_LOGO: "dotlogo",
|
||||
KIND_SCRIBBLE_LOGO: "scribblelogo",
|
||||
KIND_ACCESSORY: "accessory",
|
||||
}
|
||||
|
||||
# Files whose contents carry the programmer/supervisor password in cleartext.
|
||||
# They ARE captured - a config snapshot is the only thing that restores a
|
||||
# controller as it was - but anything that renders a snapshot to a human should
|
||||
# check this first and show the name only.
|
||||
SENSITIVE_NAMES = ("ConfigCtrl",)
|
||||
|
||||
|
||||
def is_sensitive(name):
|
||||
"""True if this file's contents hold a credential."""
|
||||
return name in SENSITIVE_NAMES
|
||||
|
||||
# name field is 11 bytes plus one reserved NUL byte
|
||||
NAME_LEN = 11
|
||||
NAME_FIELD_LEN = 12
|
||||
LIST_ENTRY_LEN = 19 # name 11 + reserved 1 + kind 1 + size 2 + reserved 4
|
||||
|
||||
RETURN_CODE_TEXT = {
|
||||
ACK: "ok",
|
||||
HT: "wrong data syntax",
|
||||
BEL: "file not found",
|
||||
LF_: "variable not found",
|
||||
}
|
||||
|
||||
STRING_ERROR_TEXT = {
|
||||
BS: "checksum error",
|
||||
HT: "bad string syntax",
|
||||
NAK: "timeout",
|
||||
}
|
||||
|
||||
|
||||
# Marking fault bits, manual fig 1. The status is 3 bytes, most significant
|
||||
# first, and the bits combine - 00 30 00 is out-of-bounds on X (10 00 + 20 00).
|
||||
FAULT_BITS = [
|
||||
(0, 0x01, "marking font"),
|
||||
(0, 0x02, "dot logo"),
|
||||
(0, 0x04, "vectorial logo"),
|
||||
(0, 0x08, "Ecc200"),
|
||||
(0, 0x10, "text zone syntax"),
|
||||
(0, 0x20, "variable"),
|
||||
(0, 0x40, "I/O"),
|
||||
(0, 0x80, "RS232"),
|
||||
(1, 0x01, "stop button activated"),
|
||||
(1, 0x02, "stylus"),
|
||||
(1, 0x04, "motor"),
|
||||
(1, 0x08, "sensor"),
|
||||
(1, 0x10, "out of marking window bounds"),
|
||||
(1, 0x20, "X axis"),
|
||||
(1, 0x40, "Y axis"),
|
||||
(1, 0x80, "accessory axis"),
|
||||
(2, 0x01, "blocked feeder, or autosensing found no part"),
|
||||
(2, 0x02, "empty feeder, part out of bounds, or binary axis"),
|
||||
(2, 0x04, "marking head lost steps"),
|
||||
(2, 0x08, "external motor"),
|
||||
(2, 0x10, "history full"),
|
||||
(2, 0x20, "duplicate detected for history"),
|
||||
(2, 0x40, "stylus needs changing"),
|
||||
(2, 0x80, "stylus has to be changed"),
|
||||
]
|
||||
|
||||
|
||||
def decode_fault(status):
|
||||
"""Turn a 3-byte marking status into readable reasons."""
|
||||
# the wire order is most significant byte first, so index 2 is Err1
|
||||
ordered = [status[2], status[1], status[0]] if len(status) == 3 else []
|
||||
reasons = []
|
||||
for index, mask, text in FAULT_BITS:
|
||||
if index < len(ordered) and ordered[index] & mask:
|
||||
reasons.append(text)
|
||||
return reasons or ["unknown status %s" % status.hex()]
|
||||
|
||||
|
||||
class E10Error(Exception):
|
||||
"""Any protocol-level failure talking to the controller."""
|
||||
|
||||
|
||||
class E10Fault(E10Error):
|
||||
"""Controller reported a fault. Carries the raw 3-byte machine status."""
|
||||
|
||||
def __init__(self, status):
|
||||
self.status = status
|
||||
self.reasons = decode_fault(status)
|
||||
super().__init__("marking fault [%s]: %s"
|
||||
% (status.hex(" "), "; ".join(self.reasons)))
|
||||
|
||||
|
||||
def encode_name(name):
|
||||
"""Pack a file name into the 12-byte name field the controller expects.
|
||||
|
||||
No spaces, 11 characters maximum, NUL padded.
|
||||
|
||||
CASE IS PRESERVED EXACTLY, and that matters. The manual says names are
|
||||
"in upper-case", but a real controller lists files named ConfigCtrl,
|
||||
File-Select and FolderDef - mixed case, and asking for CONFIGCTRL gets
|
||||
nothing back. So the manual describes a convention, not a constraint, and
|
||||
the only safe thing to send is whatever the listing gave us.
|
||||
"""
|
||||
if " " in name:
|
||||
raise E10Error("e10 file names cannot contain spaces: %r" % name)
|
||||
if len(name) > NAME_LEN:
|
||||
raise E10Error("e10 file name over %d chars: %r" % (NAME_LEN, name))
|
||||
return name.encode("ascii").ljust(NAME_FIELD_LEN, b"\x00")
|
||||
|
||||
|
||||
def decode_name(raw):
|
||||
"""Pull a file name back out of a 12-byte name field."""
|
||||
return raw[:NAME_FIELD_LEN].split(b"\x00", 1)[0].decode("ascii", "replace")
|
||||
|
||||
|
||||
class E10:
|
||||
"""One serial connection to an e10 controller.
|
||||
|
||||
Use as a context manager. Every method that reaches a part goes through the
|
||||
binary protocol; the text_* helpers exist for bench work only.
|
||||
"""
|
||||
|
||||
def __init__(self, device="/dev/ttyUSB0", timeout=2.0, checksum=False,
|
||||
debug=False):
|
||||
self.device = device
|
||||
self.timeout = timeout
|
||||
self.checksum = checksum
|
||||
self.debug = debug
|
||||
self.fd = None
|
||||
self._var_sizes = {}
|
||||
self._loaded = None
|
||||
|
||||
# ---------------------------------------------------------------- port
|
||||
|
||||
def open(self):
|
||||
self.fd = os.open(self.device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
attrs = termios.tcgetattr(self.fd)
|
||||
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = attrs
|
||||
|
||||
# raw. no canonical mode, no echo, no signal chars, no translation.
|
||||
iflag = 0
|
||||
oflag = 0
|
||||
lflag = 0
|
||||
# 8N1, receiver on, ignore modem lines. CLOCAL matters: HOST port has
|
||||
# no handshake pins, so waiting on carrier would hang forever.
|
||||
cflag = termios.CS8 | termios.CREAD | termios.CLOCAL
|
||||
ispeed = ospeed = termios.B9600
|
||||
cc = list(cc)
|
||||
cc[termios.VMIN] = 0
|
||||
cc[termios.VTIME] = 0
|
||||
|
||||
termios.tcsetattr(self.fd, termios.TCSANOW,
|
||||
[iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
|
||||
termios.tcflush(self.fd, termios.TCIOFLUSH)
|
||||
# Let anything still in flight from a previous session arrive, then
|
||||
# throw it away. Without this the first transaction reads someone
|
||||
# else's tail.
|
||||
settle = time.monotonic() + 0.25
|
||||
while time.monotonic() < settle:
|
||||
try:
|
||||
if not os.read(self.fd, 256):
|
||||
time.sleep(0.01)
|
||||
except BlockingIOError:
|
||||
time.sleep(0.01)
|
||||
termios.tcflush(self.fd, termios.TCIOFLUSH)
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
if self.fd is not None:
|
||||
os.close(self.fd)
|
||||
self.fd = None
|
||||
self._var_sizes = {}
|
||||
self._loaded = None
|
||||
|
||||
def __enter__(self):
|
||||
return self.open()
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
def _log(self, direction, data):
|
||||
if self.debug:
|
||||
sys.stderr.write("%s %s\n" % (direction, data.hex(" ")))
|
||||
|
||||
def _flush_input(self):
|
||||
"""Drop anything sitting in the receive buffer before we transmit.
|
||||
|
||||
Line noise accumulates between transactions and would otherwise be
|
||||
read back as the front of the next reply.
|
||||
"""
|
||||
try:
|
||||
termios.tcflush(self.fd, termios.TCIFLUSH)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _write(self, data):
|
||||
self._flush_input()
|
||||
self._log("TX", data)
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
offset += os.write(self.fd, data[offset:])
|
||||
|
||||
def _read_byte(self, deadline):
|
||||
"""One byte, or None once the deadline passes."""
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(self.fd, 1)
|
||||
except BlockingIOError:
|
||||
chunk = b""
|
||||
if chunk:
|
||||
return chunk[0]
|
||||
if time.monotonic() >= deadline:
|
||||
return None
|
||||
time.sleep(0.002)
|
||||
|
||||
# ------------------------------------------------------------- framing
|
||||
|
||||
def _frame(self, commands):
|
||||
"""Wrap encoded commands in a protocol string.
|
||||
|
||||
commands is already-encoded bytes, one or more [code][size][data].
|
||||
"""
|
||||
if self.checksum:
|
||||
body = bytes([STX, VERSION]) + commands + bytes([ETX])
|
||||
xor = 0
|
||||
for byte in body:
|
||||
xor ^= byte
|
||||
return body + bytes([xor])
|
||||
# NUL straight after STX is what turns checksum control off
|
||||
return bytes([STX, 0x00, VERSION]) + commands + bytes([ETX])
|
||||
|
||||
@staticmethod
|
||||
def _command(code, data=b""):
|
||||
if len(data) > 0xFFFF:
|
||||
raise E10Error("command data too long: %d bytes" % len(data))
|
||||
return bytes([code, (len(data) >> 8) & 0xFF, len(data) & 0xFF]) + data
|
||||
|
||||
def _read_response(self, timeout=None):
|
||||
"""Read one response string and return {code: data}.
|
||||
|
||||
Raises E10Error on a bare whole-string error byte.
|
||||
"""
|
||||
deadline = time.monotonic() + (self.timeout if timeout is None else timeout)
|
||||
|
||||
# HUNT FOR THE FRAME START rather than demanding it immediately.
|
||||
# Two things put junk in front of a good reply on this link: framing
|
||||
# errors from line noise arrive as 0x00, and the tail of an earlier
|
||||
# reply (an abandoned transaction, a killed script) is still in flight.
|
||||
# Neither should fail a transaction whose real answer is right behind
|
||||
# it. A frame starts with STX; a whole-string rejection is one bare
|
||||
# error byte. Anything else in between is noise worth logging and
|
||||
# skipping.
|
||||
skipped = bytearray()
|
||||
while True:
|
||||
first = self._read_byte(deadline)
|
||||
if first is None:
|
||||
if skipped:
|
||||
raise E10Error(
|
||||
"no valid frame; saw only %s" % bytes(skipped).hex(" "))
|
||||
raise E10Error("no response from controller (timeout)")
|
||||
if first == STX:
|
||||
break
|
||||
if first in STRING_ERROR_TEXT:
|
||||
raise E10Error("controller rejected the string: %s"
|
||||
% STRING_ERROR_TEXT[first])
|
||||
skipped.append(first)
|
||||
if len(skipped) > 256:
|
||||
raise E10Error("no frame start in 256 bytes: %s"
|
||||
% bytes(skipped[:32]).hex(" "))
|
||||
if skipped:
|
||||
self._log("RX skipped before STX", bytes(skipped))
|
||||
|
||||
answers = {}
|
||||
while True:
|
||||
code = self._read_byte(deadline)
|
||||
if code is None:
|
||||
raise E10Error("truncated response, no ETX")
|
||||
if code == ETX:
|
||||
break
|
||||
hi = self._read_byte(deadline)
|
||||
lo = self._read_byte(deadline)
|
||||
if hi is None or lo is None:
|
||||
raise E10Error("truncated response reading answer size")
|
||||
size = (hi << 8) | lo
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
byte = self._read_byte(deadline)
|
||||
if byte is None:
|
||||
raise E10Error("truncated response reading answer data")
|
||||
data.append(byte)
|
||||
answers[code] = bytes(data)
|
||||
|
||||
self._log("RX", b"".join(
|
||||
bytes([c]) + d for c, d in answers.items()))
|
||||
return answers
|
||||
|
||||
def _single(self, code, data=b"", timeout=None):
|
||||
"""Send one command, return its answer bytes."""
|
||||
self._write(self._frame(self._command(code, data)))
|
||||
answers = self._read_response(timeout=timeout)
|
||||
if code not in answers:
|
||||
raise E10Error("no answer for command 0x%02X" % code)
|
||||
return answers[code]
|
||||
|
||||
@staticmethod
|
||||
def _check_return_code(answer, what):
|
||||
if len(answer) != 1:
|
||||
raise E10Error("%s: expected a return code, got %d bytes"
|
||||
% (what, len(answer)))
|
||||
if answer[0] == ACK:
|
||||
return
|
||||
raise E10Error("%s: %s" % (
|
||||
what, RETURN_CODE_TEXT.get(answer[0], "code 0x%02X" % answer[0])))
|
||||
|
||||
# ------------------------------------------------------- marking path
|
||||
|
||||
def load_file(self, name, check_capacity=True):
|
||||
"""LOAD FILE 'c'. Select a marking file on the controller."""
|
||||
upper = name.upper()
|
||||
if " " in upper:
|
||||
raise E10Error("e10 file names cannot contain spaces: %r" % name)
|
||||
if not 1 <= len(upper) <= NAME_LEN:
|
||||
raise E10Error("e10 file name must be 1 to %d chars: %r"
|
||||
% (NAME_LEN, name))
|
||||
self._check_return_code(
|
||||
self._single(CMD_LOAD_FILE, upper.encode("ascii")),
|
||||
"load_file(%s)" % upper)
|
||||
self._loaded = name
|
||||
# pull the declared buffer sizes so set_var can refuse to overflow one
|
||||
if check_capacity:
|
||||
try:
|
||||
self._var_sizes = self.variable_capacities(name)
|
||||
except E10Error:
|
||||
self._var_sizes = {}
|
||||
|
||||
def set_var(self, var, value):
|
||||
"""FILE SET VAR '7'. Set one variable in the loaded file.
|
||||
|
||||
value is bytes or str. Spaces are fine - this is the whole reason the
|
||||
marking path is binary rather than text. A single space is the correct
|
||||
way to blank a field, matching what the Telesis side does.
|
||||
"""
|
||||
name = var.upper()
|
||||
if " " in name:
|
||||
raise E10Error("e10 variable names cannot contain spaces: %r" % var)
|
||||
if not 1 <= len(name) <= 20:
|
||||
raise E10Error("e10 variable name must be 1 to 20 chars: %r" % var)
|
||||
payload = value.encode("ascii") if isinstance(value, str) else bytes(value)
|
||||
if not 1 <= len(payload) <= 127:
|
||||
raise E10Error("e10 variable value must be 1 to 127 bytes, got %d"
|
||||
% len(payload))
|
||||
self._check_capacity(name, payload)
|
||||
data = name.encode("ascii") + b"=" + payload
|
||||
self._check_return_code(self._single(CMD_FILE_SET_VAR, data),
|
||||
"set_var(%s)" % name)
|
||||
|
||||
def _check_capacity(self, name, payload):
|
||||
"""Refuse to send more than the variable's declared buffer holds.
|
||||
|
||||
THE CONTROLLER TRUNCATES SILENTLY AT MARK TIME. Each variable is
|
||||
declared in the marking file with a value buffer size, and a longer
|
||||
value is cut to fit when the pattern renders - while SETVAR answers ok.
|
||||
Observed on hardware: a variable declared with a 10-byte buffer was
|
||||
given "07482 07482ABC" and the machine displayed "07482 0748".
|
||||
|
||||
GETVAR IS NO USE FOR DETECTING THIS. It echoes the full value back
|
||||
whatever the buffer size, so a read-back check passes while the mark
|
||||
would still come out short. The only honest check is against the size
|
||||
declared in the file, which is why load_file() reads it.
|
||||
|
||||
On a part marker this is the worst failure available: a truncated
|
||||
serial number engraved on a real part, reported as success.
|
||||
"""
|
||||
limit = self._var_sizes.get(name)
|
||||
if limit is None or len(payload) <= limit:
|
||||
return
|
||||
raise E10Error(
|
||||
"set_var(%s): value is %d bytes but the variable is declared with a"
|
||||
" %d-byte buffer in %s. The controller would truncate it silently"
|
||||
" and mark %r."
|
||||
% (name, len(payload), limit, self._loaded or "the loaded file",
|
||||
payload[:limit]))
|
||||
|
||||
def variable_capacities(self, name, kind=KIND_MARKING):
|
||||
"""Declared value buffer size per variable, read from the file itself.
|
||||
|
||||
Record layout: [name_len][value_size][class][00][name][value buffer].
|
||||
Records are located through the offset table at 0x0E; entry count is
|
||||
implied by the first offset. See make-e10-pattern.py for the full
|
||||
format notes.
|
||||
"""
|
||||
raw = self.read_file(name, kind=kind)
|
||||
if not raw.startswith(b"MARK_V5.0\x00"):
|
||||
return {}
|
||||
first = (raw[0x0E] << 8) | raw[0x0F]
|
||||
entries = (first - 0x0E) // 2
|
||||
if entries < 1:
|
||||
return {}
|
||||
offsets = [(raw[0x0E + 2 * i] << 8) | raw[0x0F + 2 * i]
|
||||
for i in range(entries)]
|
||||
sizes = {}
|
||||
for index, start in enumerate(offsets):
|
||||
end = offsets[index + 1] if index + 1 < len(offsets) else len(raw)
|
||||
if end <= start:
|
||||
end = len(raw)
|
||||
rec = raw[start:end]
|
||||
if (len(rec) >= 5 and rec[2] in (0x40, 0x50) and rec[3] == 0x00
|
||||
and len(rec) == 4 + rec[0] + rec[1]):
|
||||
sizes[rec[4:4 + rec[0]].decode("ascii", "replace")] = rec[1]
|
||||
return sizes
|
||||
|
||||
def clear_var(self, var):
|
||||
"""Blank a variable to a single space.
|
||||
|
||||
Mirrors the Telesis TmcClrFlds behaviour. Only reachable at all because
|
||||
the value is length-framed here.
|
||||
"""
|
||||
self.set_var(var, " ")
|
||||
|
||||
def start_marking(self, simulate=False):
|
||||
"""START MARKING 'g'. Returns once the controller has ACKed the start.
|
||||
|
||||
The cycle itself is asynchronous - call wait_for_mark() next.
|
||||
"""
|
||||
mode = b"\x01" if simulate else b"\x00"
|
||||
self._check_return_code(self._single(CMD_START_MARKING, mode),
|
||||
"start_marking")
|
||||
|
||||
def wait_for_mark(self, timeout=120.0, on_pause=None):
|
||||
"""Wait out a marking cycle. Returns only once the head is home.
|
||||
|
||||
Sequence is [EOT] last impact, then [ENQ] home. Anything that reports
|
||||
completion at EOT is lying about a head that is still moving.
|
||||
|
||||
A PAUSE line makes the controller send 'P' and wait. This NEVER answers
|
||||
it - on_pause (if given) is called so the caller can tell somebody, and
|
||||
the wait continues until a human presses Start.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
saw_eot = False
|
||||
while True:
|
||||
byte = self._read_byte(deadline)
|
||||
if byte is None:
|
||||
raise E10Error("timed out waiting for the cycle to finish"
|
||||
" (saw_eot=%s)" % saw_eot)
|
||||
if byte == EOT:
|
||||
saw_eot = True
|
||||
elif byte == ENQ:
|
||||
return # home. and only now is the cycle actually over.
|
||||
elif byte == NAK:
|
||||
status = bytearray()
|
||||
while len(status) < 3:
|
||||
part = self._read_byte(deadline)
|
||||
if part is None:
|
||||
raise E10Error("fault reported but status truncated")
|
||||
status.append(part)
|
||||
raise E10Fault(bytes(status))
|
||||
elif byte == PAUSE_ASK:
|
||||
if on_pause:
|
||||
on_pause()
|
||||
# deliberately no reply. the Start button is the only way on.
|
||||
else:
|
||||
self._log("RX unexpected", bytes([byte]))
|
||||
|
||||
def reset_error(self):
|
||||
"""RESET_ERREUR 'E'. Required after any fault before marking again."""
|
||||
self._check_return_code(self._single(CMD_RESET_ERROR), "reset_error")
|
||||
|
||||
# -------------------------------------------------------- backup path
|
||||
|
||||
def list_files(self, name=None, kind=KIND_ALL):
|
||||
"""FILE LIST 'L'. Returns [{name, kind, size}].
|
||||
|
||||
name None lists everything of the given kind.
|
||||
"""
|
||||
field = (encode_name(name) if name
|
||||
else b"\x00" * NAME_FIELD_LEN)
|
||||
answer = self._single(CMD_FILE_LIST, field + bytes([kind]))
|
||||
if len(answer) % LIST_ENTRY_LEN:
|
||||
raise E10Error("file list not a whole number of %d-byte entries"
|
||||
" (%d bytes)" % (LIST_ENTRY_LEN, len(answer)))
|
||||
files = []
|
||||
for offset in range(0, len(answer), LIST_ENTRY_LEN):
|
||||
entry = answer[offset:offset + LIST_ENTRY_LEN]
|
||||
files.append({
|
||||
"name": decode_name(entry),
|
||||
"kind": entry[12],
|
||||
"size": (entry[13] << 8) | entry[14],
|
||||
})
|
||||
return files
|
||||
|
||||
def read_file(self, name, kind=KIND_MARKING):
|
||||
"""E10_TO_PC 'S'. Pull one file whole. No block dance, unlike Telesis."""
|
||||
answer = self._single(CMD_E10_TO_PC, encode_name(name) + bytes([kind]),
|
||||
timeout=max(self.timeout, 10.0))
|
||||
if len(answer) <= NAME_FIELD_LEN + 1:
|
||||
raise E10Error("read_file(%s): controller returned no contents"
|
||||
" (file not found?)" % name)
|
||||
return answer[NAME_FIELD_LEN + 1:]
|
||||
|
||||
def write_file(self, name, contents, kind=KIND_MARKING):
|
||||
"""PC_TO_E10 'G'. Push one file back.
|
||||
|
||||
Restore path. Nothing calls this on a schedule - it exists so a
|
||||
controller swap can be put back the way it was.
|
||||
"""
|
||||
data = encode_name(name) + bytes([kind]) + bytes(contents)
|
||||
self._check_return_code(
|
||||
self._single(CMD_PC_TO_E10, data, timeout=max(self.timeout, 30.0)),
|
||||
"write_file(%s)" % name)
|
||||
|
||||
def get_machine(self):
|
||||
"""GET_MACHINE (129). Raw machine configuration bytes."""
|
||||
return self._single(CMD_GET_MACHINE)
|
||||
|
||||
# ---------------------------------------------------------- text mode
|
||||
|
||||
def text_command(self, line, timeout=None):
|
||||
"""Send one TEXT protocol line and return the reply.
|
||||
|
||||
BENCH ONLY. Do not build a marking path on this - see the module
|
||||
docstring for the three reasons. Useful for GETVERSION as a first
|
||||
contact test, and for settling whether this firmware answers SETVAR or
|
||||
SETTEXTVAR (the manual uses both, in different chapters).
|
||||
"""
|
||||
self._write(line.encode("ascii") + b"\n")
|
||||
deadline = time.monotonic() + (self.timeout if timeout is None else timeout)
|
||||
out = bytearray()
|
||||
while True:
|
||||
byte = self._read_byte(deadline)
|
||||
if byte is None:
|
||||
if out:
|
||||
break
|
||||
raise E10Error("no response to text command %r" % line)
|
||||
if byte == 0x0A: # LF ends the reply
|
||||
break
|
||||
if byte != 0x0D:
|
||||
out.append(byte)
|
||||
reply = bytes(out).decode("ascii", "replace")
|
||||
self._log("RX text", bytes(out))
|
||||
return reply
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bench client for a SIC Marking e10 controller.")
|
||||
parser.add_argument("-d", "--device", default="/dev/ttyUSB0")
|
||||
parser.add_argument("-t", "--timeout", type=float, default=2.0)
|
||||
parser.add_argument("--checksum", action="store_true",
|
||||
help="enable the XOR checksum form of the framing")
|
||||
parser.add_argument("-v", "--debug", action="store_true",
|
||||
help="hex dump every frame")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("version", help="GETVERSION over text. first contact test.")
|
||||
|
||||
p_text = sub.add_parser("text", help="send one raw text protocol line")
|
||||
p_text.add_argument("line")
|
||||
|
||||
p_list = sub.add_parser("list", help="list files on the controller")
|
||||
p_list.add_argument("--kind", type=int, default=KIND_ALL)
|
||||
|
||||
p_get = sub.add_parser("get", help="read a file off the controller")
|
||||
p_get.add_argument("name")
|
||||
p_get.add_argument("-o", "--out")
|
||||
p_get.add_argument("--kind", type=int, default=KIND_MARKING)
|
||||
|
||||
p_put = sub.add_parser("put", help="write a file to the controller")
|
||||
p_put.add_argument("name")
|
||||
p_put.add_argument("path")
|
||||
p_put.add_argument("--kind", type=int, default=KIND_MARKING)
|
||||
|
||||
p_load = sub.add_parser("load", help="select a marking file")
|
||||
p_load.add_argument("name")
|
||||
|
||||
p_set = sub.add_parser("setvar", help="set a variable in the loaded file")
|
||||
p_set.add_argument("var")
|
||||
p_set.add_argument("value")
|
||||
|
||||
p_mark = sub.add_parser("mark", help="run the loaded file")
|
||||
p_mark.add_argument("--simulate", action="store_true",
|
||||
help="force 0, stylus stays up")
|
||||
|
||||
sub.add_parser("reset", help="clear a controller fault")
|
||||
sub.add_parser("machine", help="dump machine configuration bytes")
|
||||
|
||||
p_backup = sub.add_parser(
|
||||
"backup", help="pull every marking file and logo into a directory")
|
||||
p_backup.add_argument("outdir")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
with E10(args.device, timeout=args.timeout, checksum=args.checksum,
|
||||
debug=args.debug) as e10:
|
||||
if args.cmd == "version":
|
||||
print(e10.text_command("GETVERSION"))
|
||||
|
||||
elif args.cmd == "text":
|
||||
print(e10.text_command(args.line))
|
||||
|
||||
elif args.cmd == "list":
|
||||
for entry in e10.list_files(kind=args.kind):
|
||||
print("%-12s kind=%-14s %6d bytes" % (
|
||||
entry["name"],
|
||||
KIND_NAMES.get(entry["kind"], entry["kind"]),
|
||||
entry["size"]))
|
||||
|
||||
elif args.cmd == "get":
|
||||
blob = e10.read_file(args.name, kind=args.kind)
|
||||
if args.out:
|
||||
with open(args.out, "wb") as handle:
|
||||
handle.write(blob)
|
||||
print("%s -> %s (%d bytes)" % (args.name, args.out, len(blob)))
|
||||
else:
|
||||
sys.stdout.buffer.write(blob)
|
||||
|
||||
elif args.cmd == "put":
|
||||
with open(args.path, "rb") as handle:
|
||||
blob = handle.read()
|
||||
e10.write_file(args.name, blob, kind=args.kind)
|
||||
print("%s -> controller (%d bytes)" % (args.name, len(blob)))
|
||||
|
||||
elif args.cmd == "load":
|
||||
e10.load_file(args.name)
|
||||
print("loaded %s" % args.name.upper())
|
||||
|
||||
elif args.cmd == "setvar":
|
||||
e10.set_var(args.var, args.value)
|
||||
print("%s = %r" % (args.var.upper(), args.value))
|
||||
|
||||
elif args.cmd == "mark":
|
||||
e10.start_marking(simulate=args.simulate)
|
||||
print("started, waiting for the head to come home...")
|
||||
e10.wait_for_mark(on_pause=lambda: print(
|
||||
"PAUSE line reached. press Start on the machine."))
|
||||
print("cycle complete")
|
||||
|
||||
elif args.cmd == "reset":
|
||||
e10.reset_error()
|
||||
print("fault cleared")
|
||||
|
||||
elif args.cmd == "machine":
|
||||
print(e10.get_machine().hex(" "))
|
||||
|
||||
elif args.cmd == "backup":
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
count = 0
|
||||
failed = []
|
||||
for entry in e10.list_files():
|
||||
suffix = KIND_NAMES.get(entry["kind"], "kind%s" % entry["kind"])
|
||||
# one unreadable file must not abandon the whole snapshot
|
||||
try:
|
||||
blob = e10.read_file(entry["name"], kind=entry["kind"])
|
||||
except E10Error as err:
|
||||
failed.append((entry["name"], str(err)))
|
||||
print("%-12s %6s FAILED: %s"
|
||||
% (entry["name"], "", err))
|
||||
continue
|
||||
path = os.path.join(
|
||||
args.outdir, "%s.%s" % (entry["name"], suffix))
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(blob)
|
||||
print("%-12s %6d bytes -> %s%s" % (
|
||||
entry["name"], len(blob), path,
|
||||
" [CONTAINS CREDENTIAL]" if is_sensitive(entry["name"])
|
||||
else ""))
|
||||
count += 1
|
||||
print("\n%d files captured, %d failed" % (count, len(failed)))
|
||||
for name, err in failed:
|
||||
print(" FAILED %-12s %s" % (name, err))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except E10Fault as fault:
|
||||
sys.stderr.write("controller fault: status %s\n"
|
||||
"run 'reset' before marking again\n"
|
||||
% fault.status.hex(" "))
|
||||
sys.exit(2)
|
||||
except E10Error as err:
|
||||
sys.stderr.write("e10: %s\n" % err)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user