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.
261 lines
9.3 KiB
Python
261 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Pretend to be a Telesis marking controller, so MarkTelesis will talk to us.
|
|
|
|
WHY THIS EXISTS
|
|
We need to know exactly what GE's MarkTelesis.exe puts on the wire before a
|
|
translator can impersonate a Telesis controller in front of it. A passive
|
|
tap is useless here: MarkTelesis waits for a reply to every command and
|
|
gives up after the first timeout, so a listener that never answers captures
|
|
one frame and nothing else.
|
|
|
|
So this ANSWERS. It decodes each frame, logs it, and sends back the ACK
|
|
shape MarkTelesis is parsing for. That keeps the conversation going long
|
|
enough to record a whole marking job.
|
|
|
|
It is also the first half of the real translator - the same decode, minus
|
|
the part where the other side becomes a SIC e10.
|
|
|
|
THE PROTOCOL, from disassembly of MarkTelesis.exe (2026-08-27)
|
|
request (host -> controller):
|
|
EOT SOH <TYPE> STX <data> ETX <BCC:3 ascii digits> CR
|
|
reply (controller -> host):
|
|
SOH <TYPE> ACK STX <data> ETX <BCC:3 ascii digits> CR
|
|
|
|
BCC = (TYPE + sum of data bytes) & 0xFF, as three ASCII decimal digits.
|
|
|
|
NOTE THE MISSING STATION ID. Backup470 framed requests as
|
|
EOT SOH <TYPE> <2-digit station> STX ... - MarkTelesis does NOT send those
|
|
two digits. Same protocol family, different dialect. The frame builder at
|
|
0x401cf0 pushes exactly EOT, SOH, TYPE, STX, data, ETX, BCC, CR and nothing
|
|
else.
|
|
|
|
COMMAND TYPES, all confirmed at their call sites
|
|
'P' pattern load, data is the pattern name
|
|
'1'..'9' numbered line send - the 2Line/3Line/9Line dialect. The TYPE byte
|
|
IS the line number: the loop at 0x4090b0 does `add bl, 0x31`.
|
|
'V' set field, the TMC400 dialect. Matches Message Type"="V" in
|
|
WJPRT.reg.
|
|
'O' online, empty data
|
|
'R' empty data, believed to be the mark/run trigger. INFERRED, not yet
|
|
traced to a UI action - one of the things this capture settles.
|
|
'D' pattern download, data is "<name>;<6-digit size>;128". Same write
|
|
header as Backup470.
|
|
|
|
FORCING THE ERROR PATHS
|
|
Happy-path cycles teach us almost nothing we cannot already read out of the
|
|
binary. What we actually need is how MarkTelesis behaves when things go
|
|
wrong, because the translator has to reproduce it:
|
|
|
|
--nak TYPE[,TYPE...] answer these types with NAK instead of ACK
|
|
--drop TYPE[,TYPE...] do not answer these at all, to see the retry
|
|
and timeout cadence
|
|
--delay SECONDS stall before every reply
|
|
|
|
Run it once clean, then again with --nak P (pattern not found) and once
|
|
with --drop R, and the resulting logs cover the cases that would otherwise
|
|
only show up at a bay with an operator standing there.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import termios
|
|
import time
|
|
|
|
EOT = 0x04
|
|
SOH = 0x01
|
|
STX = 0x02
|
|
ETX = 0x03
|
|
ACK = 0x06
|
|
NAK = 0x15
|
|
CR = 0x0D
|
|
|
|
TYPE_MEANING = {
|
|
"P": "pattern load",
|
|
"V": "set field (TMC400 dialect)",
|
|
"O": "online",
|
|
"R": "run / mark (inferred)",
|
|
"D": "pattern download",
|
|
"I": "status request",
|
|
}
|
|
|
|
|
|
def describe(type_char):
|
|
if type_char in TYPE_MEANING:
|
|
return TYPE_MEANING[type_char]
|
|
if type_char.isdigit() and type_char != "0":
|
|
return "line %s text (2Line/3Line/9Line dialect)" % type_char
|
|
return "unknown"
|
|
|
|
|
|
def bcc(type_char, data):
|
|
"""(TYPE + sum(data)) & 0xFF, as three ASCII decimal digits."""
|
|
total = ord(type_char)
|
|
for byte in data:
|
|
total += byte
|
|
return b"%03d" % (total & 0xFF)
|
|
|
|
|
|
class Line:
|
|
"""One serial endpoint, raw, no flow control."""
|
|
|
|
def __init__(self, device, baud=termios.B9600):
|
|
self.fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
|
attrs = termios.tcgetattr(self.fd)
|
|
cc = list(attrs[6])
|
|
cc[termios.VMIN] = 0
|
|
cc[termios.VTIME] = 0
|
|
termios.tcsetattr(self.fd, termios.TCSANOW,
|
|
[0, 0,
|
|
termios.CS8 | termios.CREAD | termios.CLOCAL,
|
|
0, baud, baud, cc])
|
|
termios.tcflush(self.fd, termios.TCIOFLUSH)
|
|
|
|
def read_byte(self, timeout=None):
|
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
while True:
|
|
try:
|
|
chunk = os.read(self.fd, 1)
|
|
except BlockingIOError:
|
|
chunk = b""
|
|
except OSError:
|
|
return None
|
|
if chunk:
|
|
return chunk[0]
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
return None
|
|
time.sleep(0.002)
|
|
|
|
def write(self, data):
|
|
offset = 0
|
|
while offset < len(data):
|
|
offset += os.write(self.fd, data[offset:])
|
|
|
|
def close(self):
|
|
os.close(self.fd)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Answer MarkTelesis as if we were a Telesis controller.")
|
|
parser.add_argument("-d", "--device", default="/dev/pts/4",
|
|
help="serial endpoint (the win11 VM's pty)")
|
|
parser.add_argument("-l", "--log", help="also append the transcript here")
|
|
parser.add_argument("--nak", default="",
|
|
help="comma-separated TYPEs to answer with NAK")
|
|
parser.add_argument("--drop", default="",
|
|
help="comma-separated TYPEs to leave unanswered")
|
|
parser.add_argument("--delay", type=float, default=0.0,
|
|
help="seconds to stall before each reply")
|
|
parser.add_argument("--status", default="0A;000",
|
|
help="data to return for a Type I status request."
|
|
" 0A;000 is a real TMC470 idle reply.")
|
|
args = parser.parse_args()
|
|
|
|
nak_types = set(t.strip() for t in args.nak.split(",") if t.strip())
|
|
drop_types = set(t.strip() for t in args.drop.split(",") if t.strip())
|
|
|
|
log_handle = open(args.log, "a") if args.log else None
|
|
|
|
def emit(text):
|
|
stamp = time.strftime("%H:%M:%S")
|
|
line = "%s %s" % (stamp, text)
|
|
print(line, flush=True)
|
|
if log_handle:
|
|
log_handle.write(line + "\n")
|
|
log_handle.flush()
|
|
|
|
line = Line(args.device)
|
|
emit("listening on %s as a Telesis controller" % args.device)
|
|
if nak_types:
|
|
emit("will NAK: %s" % ", ".join(sorted(nak_types)))
|
|
if drop_types:
|
|
emit("will DROP (no reply): %s" % ", ".join(sorted(drop_types)))
|
|
emit("waiting for MarkTelesis...")
|
|
|
|
frames = 0
|
|
try:
|
|
while True:
|
|
# requests open with EOT. anything else is noise worth seeing.
|
|
byte = line.read_byte(timeout=None)
|
|
if byte is None:
|
|
continue
|
|
if byte != EOT:
|
|
emit(" stray byte before EOT: 0x%02X" % byte)
|
|
continue
|
|
|
|
soh = line.read_byte(timeout=2)
|
|
if soh != SOH:
|
|
emit(" expected SOH after EOT, got %s"
|
|
% ("nothing" if soh is None else "0x%02X" % soh))
|
|
continue
|
|
|
|
type_byte = line.read_byte(timeout=2)
|
|
if type_byte is None:
|
|
emit(" timed out waiting for TYPE")
|
|
continue
|
|
type_char = chr(type_byte)
|
|
|
|
stx = line.read_byte(timeout=2)
|
|
if stx != STX:
|
|
emit(" expected STX, got %s"
|
|
% ("nothing" if stx is None else "0x%02X" % stx))
|
|
continue
|
|
|
|
data = bytearray()
|
|
while True:
|
|
nxt = line.read_byte(timeout=2)
|
|
if nxt is None:
|
|
emit(" timed out reading data")
|
|
break
|
|
if nxt == ETX:
|
|
break
|
|
data.append(nxt)
|
|
|
|
checksum = bytearray()
|
|
for _ in range(3):
|
|
nxt = line.read_byte(timeout=2)
|
|
if nxt is None:
|
|
break
|
|
checksum.append(nxt)
|
|
line.read_byte(timeout=1) # trailing CR
|
|
|
|
frames += 1
|
|
expected = bcc(type_char, data)
|
|
ok = "ok" if bytes(checksum) == expected else (
|
|
"MISMATCH, we computed %s" % expected.decode())
|
|
emit("RX #%d TYPE '%s' (%s) data=%r bcc=%s [%s]"
|
|
% (frames, type_char, describe(type_char), bytes(data),
|
|
bytes(checksum).decode("ascii", "replace"), ok))
|
|
|
|
if type_char in drop_types:
|
|
emit(" -> dropping, no reply (forcing a timeout)")
|
|
continue
|
|
|
|
if args.delay:
|
|
time.sleep(args.delay)
|
|
|
|
if type_char in nak_types:
|
|
reply = bytes([SOH, ord(type_char), NAK, STX, ETX]) \
|
|
+ bcc(type_char, b"") + bytes([CR])
|
|
emit(" -> NAK")
|
|
else:
|
|
payload = b""
|
|
if type_char == "I":
|
|
payload = args.status.encode("ascii")
|
|
reply = bytes([SOH, ord(type_char), ACK, STX]) + payload \
|
|
+ bytes([ETX]) + bcc(type_char, payload) + bytes([CR])
|
|
emit(" -> ACK%s" % (" data=%r" % payload if payload else ""))
|
|
line.write(reply)
|
|
|
|
except KeyboardInterrupt:
|
|
emit("stopped after %d frames" % frames)
|
|
finally:
|
|
line.close()
|
|
if log_handle:
|
|
log_handle.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|