#!/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 part number serial number dash + checksum char .. part number in 5/5/5/3 chunks operator dimension operator text reusable dimension - prompted once, reused after Wilmington takes only the last 5 characters of . That is a site rule, not a format rule, so it lives behind --site. THE WIRE FRAME (disassembled, not guessed) EOT SOH STX ETX CR BCC = (TYPE + sum of data bytes) & 0xFF NO STATION ID. Backup470 sent EOT SOH <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, ";<6-digit size>;128" TRANSLATION TO THE e10 'P' -> LOAD FILE 'c' '1'/'2'/... -> FILE SET VAR '7' into V01 / V02 / ... 'V' -> FILE SET VAR '7' into V '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 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 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) out = out.replace("", ser) out = out.replace("", "-" + serial_checksum(serial)) out = out.replace("", partno[0:5]) out = out.replace("", partno[5:10]) out = out.replace("", partno[10:15]) out = out.replace("", 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() 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) 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__": main()