Files
pxe-server/scripts/simulate-ednc.py
cproudlock eb4d067f12 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.
2026-08-27 12:57:20 -04:00

413 lines
16 KiB
Python

#!/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()