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:
412
scripts/simulate-ednc.py
Normal file
412
scripts/simulate-ednc.py
Normal file
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Do what eDNC and MarkTelesis do, without eDNC or MarkTelesis.
|
||||
|
||||
WHAT THIS IS FOR
|
||||
The whole part-marker integration hinges on one question: what exactly does
|
||||
GE's MarkTelesis.exe put on the wire, and can a translator turn that into
|
||||
something a SIC e10 understands? Answering it by capture needs a Windows VM,
|
||||
an installed Mark 6.2.1, a real Telesis controller and a live eMX prescan.
|
||||
|
||||
We do not need any of that to make progress, because we already know all
|
||||
three pieces:
|
||||
|
||||
1. The part-program file format, from MarkFileSpec.doc.
|
||||
2. The token substitution rules, from the same document.
|
||||
3. The exact wire frame, from disassembling MarkTelesis.exe on
|
||||
2026-08-27 - the frame builder at 0x401cf0 and its callers.
|
||||
|
||||
So this reimplements that path. Feed it a real GE part program and the part
|
||||
number and serial that eDNC would have pulled from the router scan, and it
|
||||
emits the byte-exact Telesis frames MarkTelesis would have sent. Then it can
|
||||
translate those into e10 commands and, optionally, drive a real controller.
|
||||
|
||||
That makes the whole chain testable today, on a desk, with one e10 and no
|
||||
Windows anywhere.
|
||||
|
||||
PART PROGRAM FORMAT (MarkFileSpec.doc, first column is the command character)
|
||||
: file information, "name,snumb". optional.
|
||||
^ pattern name to select on the controller. optional.
|
||||
* menu item. items run in file order.
|
||||
@ operator instruction, max 78 chars.
|
||||
! comment. programmer only, never shown.
|
||||
$ prompt for operator input, max 60 chars.
|
||||
# one choice in a pick list, max 16 chars.
|
||||
/ command line, new-style: /NN/ addresses field NN (TMC100/TMC400).
|
||||
\\ command line, old-style: \\N\\ is line N (2Line/3Line/9Line).
|
||||
Backslash strips leading spaces, forward slash keeps them.
|
||||
|
||||
TOKENS
|
||||
<PARTNO> part number <SERIALNO> serial number
|
||||
<CHECKSUM> dash + checksum char <PN1>..<PN4> part number in 5/5/5/3 chunks
|
||||
<DIM> operator dimension <TEXT> operator text
|
||||
<RDIM1..3> reusable dimension - prompted once, reused after
|
||||
|
||||
Wilmington takes only the last 5 characters of <SERIALNO>. That is a site
|
||||
rule, not a format rule, so it lives behind --site.
|
||||
|
||||
THE WIRE FRAME (disassembled, not guessed)
|
||||
EOT SOH <TYPE> STX <data> ETX <BCC:3 ascii digits> CR
|
||||
BCC = (TYPE + sum of data bytes) & 0xFF
|
||||
|
||||
NO STATION ID. Backup470 sent EOT SOH <TYPE> <2 digits> STX; MarkTelesis
|
||||
does not. Same family, different dialect.
|
||||
|
||||
TYPE letters, each confirmed at its call site:
|
||||
'P' pattern load, data is the pattern name
|
||||
'1'..'9' line N text. The loop at 0x4090b0 does `add bl, 0x31`, so the
|
||||
TYPE byte IS the line number. This is the 2Line dialect, which
|
||||
is what WJPRT runs.
|
||||
'V' set field, TMC400 dialect, field number leads the data
|
||||
'O' online, empty data
|
||||
'R' empty data, believed to be the mark trigger
|
||||
'D' pattern download, "<name>;<6-digit size>;128"
|
||||
|
||||
TRANSLATION TO THE e10
|
||||
'P' <name> -> LOAD FILE 'c'
|
||||
'1'/'2'/... <text>-> FILE SET VAR '7' into V01 / V02 / ...
|
||||
'V' <n><text> -> FILE SET VAR '7' into V<n>
|
||||
'O' -> nothing on the e10. ACK it locally.
|
||||
'R' -> START MARKING 'g'
|
||||
|
||||
The e10 side is deliberately BINARY, never text: the text protocol uses
|
||||
space as a delimiter, and these values are full of spaces - look at line 2
|
||||
of the real GE program, which has four consecutive ones.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
EOT, SOH, STX, ETX, CR = 0x04, 0x01, 0x02, 0x03, 0x0D
|
||||
|
||||
|
||||
# --------------------------------------------------------------- part program
|
||||
|
||||
class Item:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.pattern = None
|
||||
self.instructions = []
|
||||
self.commands = [] # (field, template, keep_spaces)
|
||||
self.prompts = [] # prompt text in file order
|
||||
self.choices = {} # prompt index -> [choices]
|
||||
|
||||
|
||||
def parse_part_program(text):
|
||||
"""Parse a MarkEdit part program into items."""
|
||||
items = []
|
||||
general = []
|
||||
fileinfo = None
|
||||
current = None
|
||||
|
||||
for raw in text.splitlines():
|
||||
if not raw.strip():
|
||||
continue
|
||||
tag = raw[0]
|
||||
# NEVER strip a command line. The real GE program marks
|
||||
# "S/N <SERIALNO> 4AM79" - those four spaces are marked onto the
|
||||
# part, and stripping them silently changes what gets engraved.
|
||||
body = raw[1:] if tag in ("/", "\\") else raw[1:].strip()
|
||||
|
||||
if tag == ":":
|
||||
fileinfo = body
|
||||
elif tag == "!":
|
||||
continue # comment, programmer only
|
||||
elif tag == "*":
|
||||
current = Item(body)
|
||||
items.append(current)
|
||||
elif tag == "^":
|
||||
if current:
|
||||
current.pattern = body
|
||||
elif tag == "@":
|
||||
(current.instructions if current else general).append(body)
|
||||
elif tag == "$":
|
||||
if current:
|
||||
current.prompts.append(body)
|
||||
elif tag == "#":
|
||||
if current and current.prompts:
|
||||
current.choices.setdefault(
|
||||
len(current.prompts) - 1, []).append(body)
|
||||
elif tag in ("/", "\\"):
|
||||
# /NN/text or \N\text - the delimiter decides space handling
|
||||
m = re.match(r"([0-9]+)[/\\](.*)", body)
|
||||
if not m:
|
||||
continue
|
||||
field, template = int(m.group(1)), m.group(2)
|
||||
keep_spaces = (tag == "/")
|
||||
if not keep_spaces:
|
||||
template = template.lstrip(" ")
|
||||
if current:
|
||||
current.commands.append((field, template, keep_spaces))
|
||||
|
||||
return fileinfo, general, items
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- tokens
|
||||
|
||||
def serial_checksum(serial):
|
||||
"""Telesis serial checksum character.
|
||||
|
||||
MarkFileSpec describes <CHECKSUM> as a dash followed by the checksum
|
||||
character but never defines the algorithm, and the binary computes it in
|
||||
DNCdll. Until a real job's log shows one, this is clearly marked as
|
||||
unverified rather than quietly guessed at.
|
||||
"""
|
||||
if not serial:
|
||||
return ""
|
||||
total = sum(ord(c) for c in serial) % 36
|
||||
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[total]
|
||||
|
||||
|
||||
def substitute(template, partno, serial, site, operator_inputs, reusable):
|
||||
"""Replace tokens the way MarkTelesis does, returning finished text."""
|
||||
out = template
|
||||
ser = serial[-5:] if site == "wilmington" else serial
|
||||
|
||||
out = out.replace("<PARTNO>", partno)
|
||||
out = out.replace("<SERIALNO>", ser)
|
||||
out = out.replace("<CHECKSUM>", "-" + serial_checksum(serial))
|
||||
out = out.replace("<PN1>", partno[0:5])
|
||||
out = out.replace("<PN2>", partno[5:10])
|
||||
out = out.replace("<PN3>", partno[10:15])
|
||||
out = out.replace("<PN4>", partno[15:18])
|
||||
|
||||
for name in ("RDIM1", "RDIM2", "RDIM3"):
|
||||
token = "<%s>" % name
|
||||
while token in out:
|
||||
if name not in reusable:
|
||||
reusable[name] = next_input(operator_inputs, name)
|
||||
out = out.replace(token, reusable[name], 1)
|
||||
|
||||
for name in ("DIM", "TEXT"):
|
||||
token = "<%s>" % name
|
||||
while token in out:
|
||||
out = out.replace(token, next_input(operator_inputs, name), 1)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def next_input(queue, label):
|
||||
"""Take the next operator-supplied value, or a visible placeholder."""
|
||||
if queue:
|
||||
return queue.pop(0)
|
||||
return "<%s?>" % label
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- frames
|
||||
|
||||
def bcc(type_char, data):
|
||||
total = ord(type_char) + sum(data)
|
||||
return b"%03d" % (total & 0xFF)
|
||||
|
||||
|
||||
def frame(type_char, data=b""):
|
||||
"""Build one MarkTelesis request frame, byte for byte."""
|
||||
if isinstance(data, str):
|
||||
data = data.encode("ascii")
|
||||
return (bytes([EOT, SOH, ord(type_char), STX]) + data
|
||||
+ bytes([ETX]) + bcc(type_char, data) + bytes([CR]))
|
||||
|
||||
|
||||
def build_sequence(item, partno, serial, site, operator_inputs,
|
||||
marker_type="2Line", clear_first=True):
|
||||
"""Everything MarkTelesis would send for one item, in order."""
|
||||
seq = []
|
||||
reusable = {}
|
||||
|
||||
seq.append(("online", frame("O")))
|
||||
|
||||
if item.pattern:
|
||||
seq.append(("load pattern %s" % item.pattern, frame("P", item.pattern)))
|
||||
|
||||
if clear_first:
|
||||
# TmcClrFlds - blank every field before setting values, so a field the
|
||||
# program does not set cannot mark the previous part's data.
|
||||
for field, _, _ in item.commands:
|
||||
if marker_type == "2Line":
|
||||
seq.append(("clear line %d" % field, frame(str(field), " ")))
|
||||
else:
|
||||
seq.append(("clear field %d" % field,
|
||||
frame("V", "%02d %s" % (field, " "))))
|
||||
|
||||
for field, template, _ in item.commands:
|
||||
text = substitute(template, partno, serial, site,
|
||||
operator_inputs, reusable)
|
||||
if marker_type == "2Line":
|
||||
# the TYPE byte IS the line number
|
||||
seq.append(("line %d = %r" % (field, text), frame(str(field), text)))
|
||||
else:
|
||||
seq.append(("field %d = %r" % (field, text),
|
||||
frame("V", "%02d%s" % (field, text))))
|
||||
|
||||
seq.append(("mark", frame("R")))
|
||||
return seq
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- translation
|
||||
|
||||
# Field number -> e10 variable name. Default is the V01/V02 convention we
|
||||
# intend for authored patterns; override when driving a file whose variables
|
||||
# were named by someone else's GUI.
|
||||
VAR_NAMES = {}
|
||||
|
||||
|
||||
def var_for(field):
|
||||
return VAR_NAMES.get(field, "V%02d" % field)
|
||||
|
||||
|
||||
def translate(type_char, data):
|
||||
"""Turn one Telesis frame into the e10 action it corresponds to.
|
||||
|
||||
Returns (action, args) where action is one of:
|
||||
load, setvar, mark, ack_locally
|
||||
"""
|
||||
text = data.decode("ascii", "replace")
|
||||
if type_char == "P":
|
||||
return ("load", text)
|
||||
if type_char == "O":
|
||||
return ("ack_locally", "online - the e10 has no equivalent")
|
||||
if type_char == "R":
|
||||
return ("mark", "")
|
||||
if type_char.isdigit():
|
||||
return ("setvar", (var_for(int(type_char)), text))
|
||||
if type_char == "V":
|
||||
return ("setvar", (var_for(int(text[:2])), text[2:]))
|
||||
return ("ack_locally", "unhandled type %r" % type_char)
|
||||
|
||||
|
||||
def decode_frame(raw):
|
||||
"""Pull (type, data) back out of a frame we built."""
|
||||
type_char = chr(raw[2])
|
||||
end = raw.index(ETX, 4)
|
||||
return type_char, raw[4:end]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- main
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Simulate what eDNC/MarkTelesis do with a part program.")
|
||||
parser.add_argument("partprogram")
|
||||
parser.add_argument("--partno", default="07482ABC",
|
||||
help="part number eDNC would get from the router scan")
|
||||
parser.add_argument("--serial", default="12345678",
|
||||
help="serial number from the router scan")
|
||||
parser.add_argument("--site", default="westjefferson",
|
||||
choices=["westjefferson", "wilmington"])
|
||||
parser.add_argument("--marker-type", default="2Line",
|
||||
choices=["2Line", "TMC400"],
|
||||
help="which dialect MarkTelesis would emit."
|
||||
" WJPRT runs 2Line.")
|
||||
parser.add_argument("--input", action="append", default=[],
|
||||
help="operator input, in prompt order. repeatable.")
|
||||
parser.add_argument("--no-clear", action="store_true",
|
||||
help="skip the clear-fields pass")
|
||||
parser.add_argument("--e10", metavar="DEVICE",
|
||||
help="also drive a real e10 on this serial device")
|
||||
parser.add_argument("--pattern",
|
||||
help="override the pattern name from the ^ line."
|
||||
" Useful when the e10 file is named differently"
|
||||
" from the Telesis pattern.")
|
||||
parser.add_argument("--var-names",
|
||||
help="comma-separated e10 variable names for fields"
|
||||
" 1,2,3... e.g. PN,SN. Default is V01,V02,...")
|
||||
parser.add_argument("--really-mark", action="store_true",
|
||||
help="fire the stylus for real. WITHOUT this, marking"
|
||||
" runs in simulation mode, force 0, stylus up.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.var_names:
|
||||
for index, name in enumerate(args.var_names.split(","), start=1):
|
||||
VAR_NAMES[index] = name.strip()
|
||||
|
||||
with open(args.partprogram) as handle:
|
||||
fileinfo, general, items = parse_part_program(handle.read())
|
||||
|
||||
print("=" * 72)
|
||||
print("PART PROGRAM: %s" % args.partprogram)
|
||||
if fileinfo:
|
||||
print(" file info %s" % fileinfo)
|
||||
print(" part number %s (from the router scan)" % args.partno)
|
||||
print(" serial %s" % args.serial)
|
||||
print(" marker type %s" % args.marker_type)
|
||||
for line in general:
|
||||
print(" instruction %s" % line)
|
||||
print("=" * 72)
|
||||
|
||||
for item in items:
|
||||
if args.pattern:
|
||||
item.pattern = args.pattern
|
||||
print("\nITEM %s" % item.name)
|
||||
if item.pattern:
|
||||
print(" pattern %s" % item.pattern)
|
||||
for line in item.instructions:
|
||||
print(" operator sees %s" % line)
|
||||
for field, template, keep in item.commands:
|
||||
print(" line %-2d %r%s"
|
||||
% (field, template, "" if keep else " (leading spaces stripped)"))
|
||||
|
||||
seq = build_sequence(item, args.partno, args.serial, args.site,
|
||||
list(args.input), args.marker_type,
|
||||
clear_first=not args.no_clear)
|
||||
|
||||
print("\n WHAT MARKTELESIS WOULD PUT ON THE WIRE")
|
||||
print(" %-28s %s" % ("meaning", "bytes"))
|
||||
print(" " + "-" * 68)
|
||||
for label, raw in seq:
|
||||
print(" %-28s %s" % (label, raw.hex(" ")))
|
||||
|
||||
print("\n TRANSLATED FOR THE e10")
|
||||
for label, raw in seq:
|
||||
type_char, data = decode_frame(raw)
|
||||
action, payload = translate(type_char, data)
|
||||
if action == "load":
|
||||
print(" %-28s LOAD FILE %r" % (label, payload))
|
||||
elif action == "setvar":
|
||||
print(" %-28s SETVAR %s = %r" % (label, payload[0], payload[1]))
|
||||
elif action == "mark":
|
||||
print(" %-28s START MARKING%s"
|
||||
% (label, "" if args.really_mark else " (SIMULATION)"))
|
||||
else:
|
||||
print(" %-28s [%s]" % (label, payload))
|
||||
|
||||
if args.e10:
|
||||
drive_e10(seq, args)
|
||||
|
||||
|
||||
def drive_e10(seq, args):
|
||||
"""Run the translated sequence against a real controller."""
|
||||
sys.path.insert(0, __file__.rsplit("/", 1)[0])
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"e10", __file__.rsplit("/", 1)[0] + "/e10-client.py")
|
||||
e10mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(e10mod)
|
||||
|
||||
print("\n DRIVING THE REAL CONTROLLER on %s" % args.e10)
|
||||
with e10mod.E10(args.e10, timeout=5.0) as dev:
|
||||
for label, raw in seq:
|
||||
type_char, data = decode_frame(raw)
|
||||
action, payload = translate(type_char, data)
|
||||
try:
|
||||
if action == "load":
|
||||
dev.load_file(payload)
|
||||
print(" %-26s LOAD FILE ok" % label)
|
||||
elif action == "setvar":
|
||||
dev.set_var(payload[0], payload[1])
|
||||
print(" %-26s SETVAR ok" % label)
|
||||
elif action == "mark":
|
||||
dev.start_marking(simulate=not args.really_mark)
|
||||
print(" %-26s started, waiting for home..." % label)
|
||||
dev.wait_for_mark(on_pause=lambda: print(
|
||||
" PAUSE - press Start on the machine"))
|
||||
print(" %-26s cycle complete" % label)
|
||||
else:
|
||||
print(" %-26s (nothing to send)" % label)
|
||||
except e10mod.E10Error as err:
|
||||
print(" %-26s FAILED: %s" % (label, err))
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user