Files
pxe-server/scripts/simulate-ednc.py
cproudlock 47f5d4603a fix(e10): --e10 passed bare silently skipped the controller
Three bugs, all found by actually running the thing rather than reading it.

1. `--e10` with no value did nothing. Making the flag optional-valued gave it
   an empty-string constant, and the code deciding whether to drive the
   controller used a truth test - so the documented `--e10` form printed the
   full translation and quietly skipped the hardware. No error, because
   nothing failed. Now gated on `is not None`, with a comment saying why.

2. A missing part program dumped a traceback. The top-level handler tidied
   e10 errors and re-raised everything else. Now a clean message that lists
   the part programs it can actually see, which also covers Windows path
   confusion.

3. Errors could vanish when piped. stdout is block-buffered and stderr is
   not, so the message printed before the output it referred to and a `| tail`
   dropped it entirely - looking like a silent failure. stdout is flushed
   before writing an error.
2026-08-27 13:55:00 -04:00

466 lines
18 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 os
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", nargs="?", const="",
help="also drive a real e10. Give a port, or pass the"
" flag bare to use $E10_PORT / the platform"
" default.")
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()
try:
with open(args.partprogram) as handle:
fileinfo, general, items = parse_part_program(handle.read())
except FileNotFoundError:
sys.stdout.flush()
sys.stderr.write("no such part program: %s\n" % args.partprogram)
here = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "partprograms")
for folder in (os.path.dirname(args.partprogram) or ".", here):
try:
found = sorted(f for f in os.listdir(folder)
if f.lower().endswith(".txt"))
except OSError:
continue
if found:
sys.stderr.write("part programs in %s:\n"
% os.path.normpath(folder))
for name in found:
sys.stderr.write(" %s\n" % name)
break
else:
sys.stderr.write("Give the path to a .txt part program. On Windows"
" use backslashes:\n"
" python scripts\\simulate-ednc.py"
" partprograms\\TEL3L0001.txt ...\n")
sys.exit(1)
except IsADirectoryError:
sys.stdout.flush()
sys.stderr.write("%s is a folder, not a part program file\n"
% args.partprogram)
sys.exit(1)
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))
# `is not None`, NOT a truth test. --e10 passed bare sets this to an
# empty string, which is falsy, so a truth test silently turns a
# hardware run into a dry run - with no error to explain why nothing
# reached the controller.
if args.e10 is not None:
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)
port = args.e10 or e10mod.default_port()
print("\n DRIVING THE REAL CONTROLLER on %s" % port)
with e10mod.E10(port, 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__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)
except Exception as err: # noqa: BLE001
# E10Error is defined in the client module, which is loaded
# dynamically, so catch by name rather than importing it up here.
if type(err).__name__ in ("E10Error", "E10Fault"):
# Flush stdout first. It is block-buffered when piped while stderr
# is not, so without this the error appears BEFORE the output it
# refers to - and a `| tail` drops it entirely, which looks like
# the command failed silently.
sys.stdout.flush()
sys.stderr.write("e10: %s\n" % err)
sys.stderr.flush()
sys.exit(1)
raise