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.
466 lines
18 KiB
Python
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
|