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.
This commit is contained in:
cproudlock
2026-08-27 12:57:20 -04:00
parent f1755751bb
commit eb4d067f12
56 changed files with 1875 additions and 0 deletions

8
.gitignore vendored
View File

@@ -118,3 +118,11 @@ playbook/shopfloor-setup/gea-shopfloor-partmarker/PartMarker/Mark/utilpassword.t
# HeatTreat per-machine DNC .reg exports (6601-6604) - contain DNC FtpPasswd
# credentials. Deployed via the enrollment share from the working tree.
playbook/shopfloor-setup/gea-shopfloor-heattreat/reg/*.reg
# SIC e10 controller config files carry the programmer/supervisor password
# in cleartext. Never commit them. See reference/e10/README.md
reference/e10/**/ConfigCtrl*
reference/e10/**/*.kind1
# GE part programs carry part numbers and marking content. Keep them out of
# git; they live in ~/pxe-images/e10-testing/partprograms/
reference/e10/partprograms/

31
reference/e10/README.md Normal file
View File

@@ -0,0 +1,31 @@
# SIC e10 reference snapshot
Pulled off the bench controller (a P63, firmware `E10 P63 (20140924)`) on
2026-08-27 with `scripts/e10-client.py backup`. 52 files: marking files,
scribble logos, and the controller's own config files.
Used as the reference corpus for reversing the `MARK_V5.0` marking file format
and for testing the e10 client without hardware attached.
## Deliberately missing: ConfigCtrl
`ConfigCtrl` is the controller configuration file. **It stores the programmer
and supervisor password in cleartext**, so it is not in this repo and must
never be committed. It is kept outside git at
`~/pxe-images/e10-ConfigCtrl-2026-08-27.kind1`, mode 0600, following the same
convention as the other site-sensitive artefacts (ADR-015).
`.gitignore` blocks `ConfigCtrl*` and `*.kind1` under this directory so a
future snapshot cannot reintroduce it by accident.
This has a direct consequence for the backup design: **any e10 snapshot that
includes the controller config contains a live credential.** Whatever ACL
protects the partmarker backup tree on the share has to account for that, and
the ShopDB asset panel must never render the file's contents.
## Part programs
GE part programs are NOT in this repo - they carry part numbers and marking
content. They live outside git at `~/pxe-images/e10-testing/partprograms/`,
alongside the testing README. `.gitignore` blocks the path here so one cannot
be added by accident.

View File

@@ -0,0 +1,51 @@
70cce4c34a91027ed94bbe8b756598eee8af17f2d002f3460bd90d990fb436b4 AROBAS.scribblelogo
10e2f1aa3071ca2d84a5349166ed3c660b8ff008eb944ef92cfc5274f005599e C151TEST.marking
d3221a50bcaca36382797b7f6e8f8e8faa50324e37a9e3b4505b3642a048493d C151ZATEST.marking
e091265e92eb2b6f205cf9df04eac49197e6af32c87b8c18142a56c2c7219b92 C153DTEST.marking
64e0af0dc88768918c855ac79476eafd4f847a6457c3cc887a67965ccf1dc207 C153TEST.marking
1e6e804d24110ae1e6737a02019ffa4b2fd5a439dbf63197e81761ff279a0042 C153ZA_TEST.marking
3b11990968ddefa701de6fdcd3b2b6c444628aa131b883c800b6cb23cdde63de C303TEST.marking
2e838035bea993f4b41ed3aef294492745d5d7ac09b46a40eca2b63e61f0e28b CE.scribblelogo
477790ff20bf8e19dfc61091af720be628cabc861062b3bfbae573cf7d2e9eb0 DIAMETR1.scribblelogo
af6bb7fc933a0204ae2d13428abf1e690b84875c77a6b135be702eff33a69cb5 DOLLAR.scribblelogo
414e0eef39661ce6974c332ff87fd1e33e32dca16d4c3fcb180844d3f712fe27 EURO.scribblelogo
e0084b66cb4a03e2aba6cc7342eead6a91610ad915d66f9ae2c734f99b772ac7 File-Select.kind1
2c04cfccfe998cec4f2174b5f4afb38f674606b1fea4de0cf27762e9d1811de9 FLECHE.scribblelogo
e54393d2f36be9c038b435cd378bef7841bdebecd2f5377ae061179250a60b46 FolderDef.kind1
0979584b27575fd47098af4c247ce3753ca230682bf81e1b4981c3473ea31da2 I111DTEST.marking
466c52d43773f34953db31812f041ed5607f6a439018382e5a4c632378609e1d I111STEST.marking
faf4e6c890038fa694efb0c5e71b90882ef963f77b88eb78cbca6b9e3913c9ed I113DTEST.marking
5c023699db1bc23f1486fbe1bd9dbf47020be0745eabe3d6a0f39f01af0d24bb I113STEST.marking
82c4eb3df4fa3c999ac852dd76ffb115e6e183e1266efbeb5e59711ad054efe3 I141_ATEST.marking
ca605734925a2369ad27370a628afc8d78197dd2916059413066282278aa064b I141TEST.marking
f60c08c64e67ee15da84e6af251f103b1851d30aa64b72ae685bd0a7d635091b I52TEST.marking
cb5c02fad724be06f5ca7ec2e07684ca1c6e876dbe9fc93d8c5b2a4d027b76e5 I61HATEST.marking
ab7e80458d29f40643c5f29b028998ee461449c2fb60654b8eb7d1db0dac66d9 I61STEST.marking
a1a649f60076a12af8cebf7dddd21a6a50b4679e2263a0959880fe634ee19395 I81-ATEST.marking
276157a44ad7b0d5b658c0207b66344f1b6c7747d600435150b4f08cb3c2319d I81TEST.marking
a3958d8271b395431cbbd9b70fbf992bec29d4fdc50aed3a89352b94040ce3ce MIRE.scribblelogo
51f32036eafdc34ee296c7af0f99497961a85c88e9490698782f80d0f0bbd5a4 NEW.marking
517b1254b6c7636d25cee78c41d4164d523069db6477119c7b3934c9953a3092 NF.scribblelogo
e9c628286a4a5e8dd21e40524081dc16f56e7d252eff4a0bc60885153d677836 P122_40TEST.marking
87899b8fcd02a53ce2b7cece87b22339a326e0d0e3a4471d405413623bf9f8ce P122TEST.marking
fa1dc4effb6af039c47665597183030bf77939e68f0e6ee7c21dce6b258ed249 P62TEST.marking
ed5c962db3376f43ec5a0b0bdeb3d42df0d08671d86d199995dbd065ef27cd72 PHONE.scribblelogo
7a8749129203e4729a3f40b63d98963ce658c5c523d701a20817cc243375a598 POWER.scribblelogo
b280f55cefb8467f2e5bc72bf573ac1b12e2c37aaed7b65cc01994cfd596a9bb RECYCLAG.scribblelogo
49bf169537af2b2d830331dd8d263e684bc6a831cfcaf2dadd7d3005c80507f2 ROUND.scribblelogo
f7d6a767b43d8d990937edc87c64fe16e3df48a2a62bdd182d8ee0570dbb6b65 RROUND.scribblelogo
b63008e8a99d098774db34e5eb759ee7ce2cb6935061aad4f4639ad1d3101214 RU.marking
befeb2d21947be021bfa8bcbacfa1a1a9410cf07a1502425d16dae0efedad3b3 SAVE_ACC_1.kind7
d9cb28a5ca4742d2a99b302cada595171d9aba83892973f6c09a00f5a04b9be8 SENSINTE1.scribblelogo
90d36b9d0167d6343fb71fbc65750d4f162a31d394bf6fd15700481608b66b43 SIC.scribblelogo
5223b4860a45e531c04ea68038e5654504ff07968cb31ed409879dafc3dcd3d6 SQUARE.scribblelogo
a3ffe9466ef36622242e9b878cf3a5059428db3580fe623f99a62b0f0bff6252 STAR.scribblelogo
7fe8657ba86d3d048d5b1ff529bde7dcd138946a8aed54284d51f959d8d7815c TENSION.scribblelogo
c0061f304587e65597a6a685d009f5e36dae49aa662f5cbc2ed8bc8d5c13c124 TERRE.scribblelogo
6dacf2f4ce950e9a29bdc525b43966fe05e00166d668a6108e48a89a1e4463c4 TEST_DB1.marking
e510e74205dc57d106c0ab5484200a7a7ba8616c3d9580bc45500be00b04dc6f TEST_DB2.marking
3aefdb0845861635ba9c9bbe73ebb020736786ff688267a1e048f281481d20a5 TEST_DB3.marking
95a4056868e686d14766d74158ac781b91903df158fde5691f003d3aca0324be TEST.marking
de6574fa8579b4fe1d2d9f627f9f203354eafd2a2facbf183c1656d2f2d053d2 TESTP120.marking
f6a519fb92aff6efdd59caaded3728f38b878b83fa51ac37ca081b452a8d960c TEST_RU.marking
73aa19ae9f2feef8b85dc5de2659b097b8580470be69c2afd758fe30b6def398 TRIANGLE.scribblelogo

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
зз##0>
M^n~
<EFBFBD><10>Ќ#Й0У>ЬMв^жnз~ж<>в<EFBFBD>ЬЌУЙЙУЌЬ<D08C>в<EFBFBD>ж~зnж^вMЬ>У0Й#Ќ<19><10>
~n^M
>0##н"ИЙ"нн

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

829
scripts/e10-client.py Normal file
View File

@@ -0,0 +1,829 @@
#!/usr/bin/env python3
"""Talk to a SIC Marking e10-V6 marking controller over RS-232.
Written against NOTPROTOE10V6US-R08 (the e10-V6 communication protocol manual)
and VERIFIED against a real controller on 2026-08-27 - a P63, firmware
"E10 P63 (20140924)". Listing, reading and the machine query all work over the
HOST port; a 52-file snapshot came off it in one command.
Two things the hardware taught us that the manual gets wrong:
- File names are CASE-SENSITIVE and not always uppercase (see encode_name).
- File kinds 1 and 7 exist and are readable, though the manual lists only
0/2/4/5. Kind 1 is where the controller configuration lives, which is what
makes a complete settings snapshot possible.
Still unverified against hardware: the marking path itself (LOAD FILE,
FILE SET VAR, START MARKING). Those move a stylus, so they wait for a
deliberate session rather than a probe.
WHY BINARY AND NOT TEXT
The manual defines two protocols. The TEXT one is easier to read and is
what SIC's own support quoted at us, but it cannot carry the values this
integration needs:
1. Space is the TEXT protocol's delimiter - data is defined as "the data
of the command ( without Space char)". The GE marker software blanks
every field to a SINGLE SPACE before setting values ("TmcClrFlds: Put
a SPACE in each field."). A value that IS a space is unrepresentable.
2. Real GE part programs mark text containing runs of spaces, e.g.
"S/N <SERIALNO> 4AM79". TEXT protocol handling of embedded and
consecutive spaces is undefined by the manual.
3. TEXT frames carry NO checksum of any kind. The Telesis protocol this
replaces has a BCC on every frame in both directions.
BINARY FILE SET VAR ('7') carries the value as length-framed bytes, 1 to
127 of them, so a lone space is legal and space runs survive. And the
binary framing has an optional XOR checksum. Hence: binary for anything
that reaches a part, text for bench poking only.
FRAMING (manual section I.3.a)
without checksum: [STX][NUL][VER] <command>... [ETX]
with checksum: [STX][VER] <command>... [ETX][XOR]
VER is '5' (0x35). The XOR covers every byte of the string INCLUDING STX
and ETX. A NUL directly after STX is what disables checksum control, which
is why every worked example in the manual starts 02 00 35.
command: [code][size hi][size lo][data] (size is big-endian)
The manual also defines a break-code command form for when you do not want
to count the data. Not implemented - the length form is unambiguous and we
always know our own lengths.
RESPONSES (manual section I.3.b)
Whole-string errors arrive as one bare byte:
BS checksum error HT bad string syntax NAK timeout
Otherwise: [STX] <answer>... [ETX]
where answer is [original code][size hi][size lo][data], and data is very
often a single return code:
ACK ok HT wrong data syntax
BEL file not found LF variable not found
MARKING COMPLETION IS ASYNCHRONOUS AND TWO-STAGE (manual, START MARKING 'g')
After the ACK for the start command:
[EOT] last impact done
[ENQ] head back at home position
[NAK][status(3)] fault, and the error must then be reset
DONE MEANS ENQ, NOT EOT. Calling it done at EOT reports a finished cycle
while the head is still travelling, which at a bay means an operator
reaching for a part that is still moving.
PAUSE LINES ARE NOT OURS TO ANSWER
At a PAUSE line the controller sends 'P' and waits for either 'p' on the
serial line or the physical Start button. This client NEVER sends 'p'. A
pause exists so a human can reposition the part; answering it from software
resumes marking on a part nobody has touched. wait_for_mark() surfaces the
pause to the caller and keeps waiting.
DELETE IS DELIBERATELY ABSENT
DEL_FILE ('D') exists and is documented. It is not implemented here, for
the same reason Telesis TYPE 'R' was left out of TelesisMarker.psm1: a
backup tool that can delete is a backup tool that can be made to delete.
LINK LAYER
e10 HOST port is fixed at 9600-8-N-1 and cannot be reconfigured. Pins
4/6/7/8 are NC, so there are no handshake lines and hardware flow control
is impossible - the port is opened with flow control off and the manual's
own backpressure rule applies instead: 500-byte controller buffer, do not
push faster than it drains, 40000 bytes maximum per string.
Both ends are DTE (each calls pin 2 RX and pin 3 TX), so the cable must be
a crossover: 3 to 2, 2 to 3, 5 to 5.
Stdlib only, termios - no pyserial on the bench box.
"""
import argparse
import os
import sys
import termios
import time
# framing
STX = 0x02
ETX = 0x03
VERSION = 0x35 # '5', protocol version byte
# return codes and whole-string errors
EOT = 0x04 # last impact marked
ENQ = 0x05 # back at home position
ACK = 0x06 # success
BEL = 0x07 # file not found
BS = 0x08 # checksum error (whole string)
HT = 0x09 # bad syntax
LF_ = 0x0A # variable not found
NAK = 0x15 # timeout, or fault during marking
PAUSE_ASK = 0x50 # 'P', controller waiting at a PAUSE line
PAUSE_ANSWER = 0x70 # 'p'. deliberately never sent. see module docstring.
# command codes, manual section I.3.c
CMD_LOAD_FILE = 0x63 # 'c'
CMD_FILE_SET_VAR = 0x37 # '7'
CMD_START_MARKING = 0x67 # 'g'
CMD_RESET_ERROR = 0x45 # 'E'
CMD_FILE_LIST = 0x4C # 'L'
CMD_PC_TO_E10 = 0x47 # 'G'
CMD_E10_TO_PC = 0x53 # 'S'
CMD_GET_MACHINE = 0x81
# file kinds, manual FILE LIST
KIND_ALL = 0
# 1 and 7 are UNDOCUMENTED - the manual lists only 0/2/4/5. Found on a real
# controller: kind 1 holds ConfigCtrl (the controller configuration),
# File-Select (the digital-input file selection map) and FolderDef; kind 7 held
# SAVE_ACC_1, which looks like accessory or axis data. They pull fine over
# E10_TO_PC, which is what makes a complete settings snapshot possible at all.
KIND_CONFIG = 1
KIND_MARKING = 2
KIND_DOT_LOGO = 4
KIND_SCRIBBLE_LOGO = 5
KIND_ACCESSORY = 7
KIND_NAMES = {
KIND_CONFIG: "config",
KIND_MARKING: "marking",
KIND_DOT_LOGO: "dotlogo",
KIND_SCRIBBLE_LOGO: "scribblelogo",
KIND_ACCESSORY: "accessory",
}
# Files whose contents carry the programmer/supervisor password in cleartext.
# They ARE captured - a config snapshot is the only thing that restores a
# controller as it was - but anything that renders a snapshot to a human should
# check this first and show the name only.
SENSITIVE_NAMES = ("ConfigCtrl",)
def is_sensitive(name):
"""True if this file's contents hold a credential."""
return name in SENSITIVE_NAMES
# name field is 11 bytes plus one reserved NUL byte
NAME_LEN = 11
NAME_FIELD_LEN = 12
LIST_ENTRY_LEN = 19 # name 11 + reserved 1 + kind 1 + size 2 + reserved 4
RETURN_CODE_TEXT = {
ACK: "ok",
HT: "wrong data syntax",
BEL: "file not found",
LF_: "variable not found",
}
STRING_ERROR_TEXT = {
BS: "checksum error",
HT: "bad string syntax",
NAK: "timeout",
}
# Marking fault bits, manual fig 1. The status is 3 bytes, most significant
# first, and the bits combine - 00 30 00 is out-of-bounds on X (10 00 + 20 00).
FAULT_BITS = [
(0, 0x01, "marking font"),
(0, 0x02, "dot logo"),
(0, 0x04, "vectorial logo"),
(0, 0x08, "Ecc200"),
(0, 0x10, "text zone syntax"),
(0, 0x20, "variable"),
(0, 0x40, "I/O"),
(0, 0x80, "RS232"),
(1, 0x01, "stop button activated"),
(1, 0x02, "stylus"),
(1, 0x04, "motor"),
(1, 0x08, "sensor"),
(1, 0x10, "out of marking window bounds"),
(1, 0x20, "X axis"),
(1, 0x40, "Y axis"),
(1, 0x80, "accessory axis"),
(2, 0x01, "blocked feeder, or autosensing found no part"),
(2, 0x02, "empty feeder, part out of bounds, or binary axis"),
(2, 0x04, "marking head lost steps"),
(2, 0x08, "external motor"),
(2, 0x10, "history full"),
(2, 0x20, "duplicate detected for history"),
(2, 0x40, "stylus needs changing"),
(2, 0x80, "stylus has to be changed"),
]
def decode_fault(status):
"""Turn a 3-byte marking status into readable reasons."""
# the wire order is most significant byte first, so index 2 is Err1
ordered = [status[2], status[1], status[0]] if len(status) == 3 else []
reasons = []
for index, mask, text in FAULT_BITS:
if index < len(ordered) and ordered[index] & mask:
reasons.append(text)
return reasons or ["unknown status %s" % status.hex()]
class E10Error(Exception):
"""Any protocol-level failure talking to the controller."""
class E10Fault(E10Error):
"""Controller reported a fault. Carries the raw 3-byte machine status."""
def __init__(self, status):
self.status = status
self.reasons = decode_fault(status)
super().__init__("marking fault [%s]: %s"
% (status.hex(" "), "; ".join(self.reasons)))
def encode_name(name):
"""Pack a file name into the 12-byte name field the controller expects.
No spaces, 11 characters maximum, NUL padded.
CASE IS PRESERVED EXACTLY, and that matters. The manual says names are
"in upper-case", but a real controller lists files named ConfigCtrl,
File-Select and FolderDef - mixed case, and asking for CONFIGCTRL gets
nothing back. So the manual describes a convention, not a constraint, and
the only safe thing to send is whatever the listing gave us.
"""
if " " in name:
raise E10Error("e10 file names cannot contain spaces: %r" % name)
if len(name) > NAME_LEN:
raise E10Error("e10 file name over %d chars: %r" % (NAME_LEN, name))
return name.encode("ascii").ljust(NAME_FIELD_LEN, b"\x00")
def decode_name(raw):
"""Pull a file name back out of a 12-byte name field."""
return raw[:NAME_FIELD_LEN].split(b"\x00", 1)[0].decode("ascii", "replace")
class E10:
"""One serial connection to an e10 controller.
Use as a context manager. Every method that reaches a part goes through the
binary protocol; the text_* helpers exist for bench work only.
"""
def __init__(self, device="/dev/ttyUSB0", timeout=2.0, checksum=False,
debug=False):
self.device = device
self.timeout = timeout
self.checksum = checksum
self.debug = debug
self.fd = None
self._var_sizes = {}
self._loaded = None
# ---------------------------------------------------------------- port
def open(self):
self.fd = os.open(self.device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
attrs = termios.tcgetattr(self.fd)
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = attrs
# raw. no canonical mode, no echo, no signal chars, no translation.
iflag = 0
oflag = 0
lflag = 0
# 8N1, receiver on, ignore modem lines. CLOCAL matters: HOST port has
# no handshake pins, so waiting on carrier would hang forever.
cflag = termios.CS8 | termios.CREAD | termios.CLOCAL
ispeed = ospeed = termios.B9600
cc = list(cc)
cc[termios.VMIN] = 0
cc[termios.VTIME] = 0
termios.tcsetattr(self.fd, termios.TCSANOW,
[iflag, oflag, cflag, lflag, ispeed, ospeed, cc])
termios.tcflush(self.fd, termios.TCIOFLUSH)
# Let anything still in flight from a previous session arrive, then
# throw it away. Without this the first transaction reads someone
# else's tail.
settle = time.monotonic() + 0.25
while time.monotonic() < settle:
try:
if not os.read(self.fd, 256):
time.sleep(0.01)
except BlockingIOError:
time.sleep(0.01)
termios.tcflush(self.fd, termios.TCIOFLUSH)
return self
def close(self):
if self.fd is not None:
os.close(self.fd)
self.fd = None
self._var_sizes = {}
self._loaded = None
def __enter__(self):
return self.open()
def __exit__(self, *exc):
self.close()
def _log(self, direction, data):
if self.debug:
sys.stderr.write("%s %s\n" % (direction, data.hex(" ")))
def _flush_input(self):
"""Drop anything sitting in the receive buffer before we transmit.
Line noise accumulates between transactions and would otherwise be
read back as the front of the next reply.
"""
try:
termios.tcflush(self.fd, termios.TCIFLUSH)
except OSError:
pass
def _write(self, data):
self._flush_input()
self._log("TX", data)
offset = 0
while offset < len(data):
offset += os.write(self.fd, data[offset:])
def _read_byte(self, deadline):
"""One byte, or None once the deadline passes."""
while True:
try:
chunk = os.read(self.fd, 1)
except BlockingIOError:
chunk = b""
if chunk:
return chunk[0]
if time.monotonic() >= deadline:
return None
time.sleep(0.002)
# ------------------------------------------------------------- framing
def _frame(self, commands):
"""Wrap encoded commands in a protocol string.
commands is already-encoded bytes, one or more [code][size][data].
"""
if self.checksum:
body = bytes([STX, VERSION]) + commands + bytes([ETX])
xor = 0
for byte in body:
xor ^= byte
return body + bytes([xor])
# NUL straight after STX is what turns checksum control off
return bytes([STX, 0x00, VERSION]) + commands + bytes([ETX])
@staticmethod
def _command(code, data=b""):
if len(data) > 0xFFFF:
raise E10Error("command data too long: %d bytes" % len(data))
return bytes([code, (len(data) >> 8) & 0xFF, len(data) & 0xFF]) + data
def _read_response(self, timeout=None):
"""Read one response string and return {code: data}.
Raises E10Error on a bare whole-string error byte.
"""
deadline = time.monotonic() + (self.timeout if timeout is None else timeout)
# HUNT FOR THE FRAME START rather than demanding it immediately.
# Two things put junk in front of a good reply on this link: framing
# errors from line noise arrive as 0x00, and the tail of an earlier
# reply (an abandoned transaction, a killed script) is still in flight.
# Neither should fail a transaction whose real answer is right behind
# it. A frame starts with STX; a whole-string rejection is one bare
# error byte. Anything else in between is noise worth logging and
# skipping.
skipped = bytearray()
while True:
first = self._read_byte(deadline)
if first is None:
if skipped:
raise E10Error(
"no valid frame; saw only %s" % bytes(skipped).hex(" "))
raise E10Error("no response from controller (timeout)")
if first == STX:
break
if first in STRING_ERROR_TEXT:
raise E10Error("controller rejected the string: %s"
% STRING_ERROR_TEXT[first])
skipped.append(first)
if len(skipped) > 256:
raise E10Error("no frame start in 256 bytes: %s"
% bytes(skipped[:32]).hex(" "))
if skipped:
self._log("RX skipped before STX", bytes(skipped))
answers = {}
while True:
code = self._read_byte(deadline)
if code is None:
raise E10Error("truncated response, no ETX")
if code == ETX:
break
hi = self._read_byte(deadline)
lo = self._read_byte(deadline)
if hi is None or lo is None:
raise E10Error("truncated response reading answer size")
size = (hi << 8) | lo
data = bytearray()
while len(data) < size:
byte = self._read_byte(deadline)
if byte is None:
raise E10Error("truncated response reading answer data")
data.append(byte)
answers[code] = bytes(data)
self._log("RX", b"".join(
bytes([c]) + d for c, d in answers.items()))
return answers
def _single(self, code, data=b"", timeout=None):
"""Send one command, return its answer bytes."""
self._write(self._frame(self._command(code, data)))
answers = self._read_response(timeout=timeout)
if code not in answers:
raise E10Error("no answer for command 0x%02X" % code)
return answers[code]
@staticmethod
def _check_return_code(answer, what):
if len(answer) != 1:
raise E10Error("%s: expected a return code, got %d bytes"
% (what, len(answer)))
if answer[0] == ACK:
return
raise E10Error("%s: %s" % (
what, RETURN_CODE_TEXT.get(answer[0], "code 0x%02X" % answer[0])))
# ------------------------------------------------------- marking path
def load_file(self, name, check_capacity=True):
"""LOAD FILE 'c'. Select a marking file on the controller."""
upper = name.upper()
if " " in upper:
raise E10Error("e10 file names cannot contain spaces: %r" % name)
if not 1 <= len(upper) <= NAME_LEN:
raise E10Error("e10 file name must be 1 to %d chars: %r"
% (NAME_LEN, name))
self._check_return_code(
self._single(CMD_LOAD_FILE, upper.encode("ascii")),
"load_file(%s)" % upper)
self._loaded = name
# pull the declared buffer sizes so set_var can refuse to overflow one
if check_capacity:
try:
self._var_sizes = self.variable_capacities(name)
except E10Error:
self._var_sizes = {}
def set_var(self, var, value):
"""FILE SET VAR '7'. Set one variable in the loaded file.
value is bytes or str. Spaces are fine - this is the whole reason the
marking path is binary rather than text. A single space is the correct
way to blank a field, matching what the Telesis side does.
"""
name = var.upper()
if " " in name:
raise E10Error("e10 variable names cannot contain spaces: %r" % var)
if not 1 <= len(name) <= 20:
raise E10Error("e10 variable name must be 1 to 20 chars: %r" % var)
payload = value.encode("ascii") if isinstance(value, str) else bytes(value)
if not 1 <= len(payload) <= 127:
raise E10Error("e10 variable value must be 1 to 127 bytes, got %d"
% len(payload))
self._check_capacity(name, payload)
data = name.encode("ascii") + b"=" + payload
self._check_return_code(self._single(CMD_FILE_SET_VAR, data),
"set_var(%s)" % name)
def _check_capacity(self, name, payload):
"""Refuse to send more than the variable's declared buffer holds.
THE CONTROLLER TRUNCATES SILENTLY AT MARK TIME. Each variable is
declared in the marking file with a value buffer size, and a longer
value is cut to fit when the pattern renders - while SETVAR answers ok.
Observed on hardware: a variable declared with a 10-byte buffer was
given "07482 07482ABC" and the machine displayed "07482 0748".
GETVAR IS NO USE FOR DETECTING THIS. It echoes the full value back
whatever the buffer size, so a read-back check passes while the mark
would still come out short. The only honest check is against the size
declared in the file, which is why load_file() reads it.
On a part marker this is the worst failure available: a truncated
serial number engraved on a real part, reported as success.
"""
limit = self._var_sizes.get(name)
if limit is None or len(payload) <= limit:
return
raise E10Error(
"set_var(%s): value is %d bytes but the variable is declared with a"
" %d-byte buffer in %s. The controller would truncate it silently"
" and mark %r."
% (name, len(payload), limit, self._loaded or "the loaded file",
payload[:limit]))
def variable_capacities(self, name, kind=KIND_MARKING):
"""Declared value buffer size per variable, read from the file itself.
Record layout: [name_len][value_size][class][00][name][value buffer].
Records are located through the offset table at 0x0E; entry count is
implied by the first offset. See make-e10-pattern.py for the full
format notes.
"""
raw = self.read_file(name, kind=kind)
if not raw.startswith(b"MARK_V5.0\x00"):
return {}
first = (raw[0x0E] << 8) | raw[0x0F]
entries = (first - 0x0E) // 2
if entries < 1:
return {}
offsets = [(raw[0x0E + 2 * i] << 8) | raw[0x0F + 2 * i]
for i in range(entries)]
sizes = {}
for index, start in enumerate(offsets):
end = offsets[index + 1] if index + 1 < len(offsets) else len(raw)
if end <= start:
end = len(raw)
rec = raw[start:end]
if (len(rec) >= 5 and rec[2] in (0x40, 0x50) and rec[3] == 0x00
and len(rec) == 4 + rec[0] + rec[1]):
sizes[rec[4:4 + rec[0]].decode("ascii", "replace")] = rec[1]
return sizes
def clear_var(self, var):
"""Blank a variable to a single space.
Mirrors the Telesis TmcClrFlds behaviour. Only reachable at all because
the value is length-framed here.
"""
self.set_var(var, " ")
def start_marking(self, simulate=False):
"""START MARKING 'g'. Returns once the controller has ACKed the start.
The cycle itself is asynchronous - call wait_for_mark() next.
"""
mode = b"\x01" if simulate else b"\x00"
self._check_return_code(self._single(CMD_START_MARKING, mode),
"start_marking")
def wait_for_mark(self, timeout=120.0, on_pause=None):
"""Wait out a marking cycle. Returns only once the head is home.
Sequence is [EOT] last impact, then [ENQ] home. Anything that reports
completion at EOT is lying about a head that is still moving.
A PAUSE line makes the controller send 'P' and wait. This NEVER answers
it - on_pause (if given) is called so the caller can tell somebody, and
the wait continues until a human presses Start.
"""
deadline = time.monotonic() + timeout
saw_eot = False
while True:
byte = self._read_byte(deadline)
if byte is None:
raise E10Error("timed out waiting for the cycle to finish"
" (saw_eot=%s)" % saw_eot)
if byte == EOT:
saw_eot = True
elif byte == ENQ:
return # home. and only now is the cycle actually over.
elif byte == NAK:
status = bytearray()
while len(status) < 3:
part = self._read_byte(deadline)
if part is None:
raise E10Error("fault reported but status truncated")
status.append(part)
raise E10Fault(bytes(status))
elif byte == PAUSE_ASK:
if on_pause:
on_pause()
# deliberately no reply. the Start button is the only way on.
else:
self._log("RX unexpected", bytes([byte]))
def reset_error(self):
"""RESET_ERREUR 'E'. Required after any fault before marking again."""
self._check_return_code(self._single(CMD_RESET_ERROR), "reset_error")
# -------------------------------------------------------- backup path
def list_files(self, name=None, kind=KIND_ALL):
"""FILE LIST 'L'. Returns [{name, kind, size}].
name None lists everything of the given kind.
"""
field = (encode_name(name) if name
else b"\x00" * NAME_FIELD_LEN)
answer = self._single(CMD_FILE_LIST, field + bytes([kind]))
if len(answer) % LIST_ENTRY_LEN:
raise E10Error("file list not a whole number of %d-byte entries"
" (%d bytes)" % (LIST_ENTRY_LEN, len(answer)))
files = []
for offset in range(0, len(answer), LIST_ENTRY_LEN):
entry = answer[offset:offset + LIST_ENTRY_LEN]
files.append({
"name": decode_name(entry),
"kind": entry[12],
"size": (entry[13] << 8) | entry[14],
})
return files
def read_file(self, name, kind=KIND_MARKING):
"""E10_TO_PC 'S'. Pull one file whole. No block dance, unlike Telesis."""
answer = self._single(CMD_E10_TO_PC, encode_name(name) + bytes([kind]),
timeout=max(self.timeout, 10.0))
if len(answer) <= NAME_FIELD_LEN + 1:
raise E10Error("read_file(%s): controller returned no contents"
" (file not found?)" % name)
return answer[NAME_FIELD_LEN + 1:]
def write_file(self, name, contents, kind=KIND_MARKING):
"""PC_TO_E10 'G'. Push one file back.
Restore path. Nothing calls this on a schedule - it exists so a
controller swap can be put back the way it was.
"""
data = encode_name(name) + bytes([kind]) + bytes(contents)
self._check_return_code(
self._single(CMD_PC_TO_E10, data, timeout=max(self.timeout, 30.0)),
"write_file(%s)" % name)
def get_machine(self):
"""GET_MACHINE (129). Raw machine configuration bytes."""
return self._single(CMD_GET_MACHINE)
# ---------------------------------------------------------- text mode
def text_command(self, line, timeout=None):
"""Send one TEXT protocol line and return the reply.
BENCH ONLY. Do not build a marking path on this - see the module
docstring for the three reasons. Useful for GETVERSION as a first
contact test, and for settling whether this firmware answers SETVAR or
SETTEXTVAR (the manual uses both, in different chapters).
"""
self._write(line.encode("ascii") + b"\n")
deadline = time.monotonic() + (self.timeout if timeout is None else timeout)
out = bytearray()
while True:
byte = self._read_byte(deadline)
if byte is None:
if out:
break
raise E10Error("no response to text command %r" % line)
if byte == 0x0A: # LF ends the reply
break
if byte != 0x0D:
out.append(byte)
reply = bytes(out).decode("ascii", "replace")
self._log("RX text", bytes(out))
return reply
# ------------------------------------------------------------------- CLI
def main():
parser = argparse.ArgumentParser(
description="Bench client for a SIC Marking e10 controller.")
parser.add_argument("-d", "--device", default="/dev/ttyUSB0")
parser.add_argument("-t", "--timeout", type=float, default=2.0)
parser.add_argument("--checksum", action="store_true",
help="enable the XOR checksum form of the framing")
parser.add_argument("-v", "--debug", action="store_true",
help="hex dump every frame")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("version", help="GETVERSION over text. first contact test.")
p_text = sub.add_parser("text", help="send one raw text protocol line")
p_text.add_argument("line")
p_list = sub.add_parser("list", help="list files on the controller")
p_list.add_argument("--kind", type=int, default=KIND_ALL)
p_get = sub.add_parser("get", help="read a file off the controller")
p_get.add_argument("name")
p_get.add_argument("-o", "--out")
p_get.add_argument("--kind", type=int, default=KIND_MARKING)
p_put = sub.add_parser("put", help="write a file to the controller")
p_put.add_argument("name")
p_put.add_argument("path")
p_put.add_argument("--kind", type=int, default=KIND_MARKING)
p_load = sub.add_parser("load", help="select a marking file")
p_load.add_argument("name")
p_set = sub.add_parser("setvar", help="set a variable in the loaded file")
p_set.add_argument("var")
p_set.add_argument("value")
p_mark = sub.add_parser("mark", help="run the loaded file")
p_mark.add_argument("--simulate", action="store_true",
help="force 0, stylus stays up")
sub.add_parser("reset", help="clear a controller fault")
sub.add_parser("machine", help="dump machine configuration bytes")
p_backup = sub.add_parser(
"backup", help="pull every marking file and logo into a directory")
p_backup.add_argument("outdir")
args = parser.parse_args()
with E10(args.device, timeout=args.timeout, checksum=args.checksum,
debug=args.debug) as e10:
if args.cmd == "version":
print(e10.text_command("GETVERSION"))
elif args.cmd == "text":
print(e10.text_command(args.line))
elif args.cmd == "list":
for entry in e10.list_files(kind=args.kind):
print("%-12s kind=%-14s %6d bytes" % (
entry["name"],
KIND_NAMES.get(entry["kind"], entry["kind"]),
entry["size"]))
elif args.cmd == "get":
blob = e10.read_file(args.name, kind=args.kind)
if args.out:
with open(args.out, "wb") as handle:
handle.write(blob)
print("%s -> %s (%d bytes)" % (args.name, args.out, len(blob)))
else:
sys.stdout.buffer.write(blob)
elif args.cmd == "put":
with open(args.path, "rb") as handle:
blob = handle.read()
e10.write_file(args.name, blob, kind=args.kind)
print("%s -> controller (%d bytes)" % (args.name, len(blob)))
elif args.cmd == "load":
e10.load_file(args.name)
print("loaded %s" % args.name.upper())
elif args.cmd == "setvar":
e10.set_var(args.var, args.value)
print("%s = %r" % (args.var.upper(), args.value))
elif args.cmd == "mark":
e10.start_marking(simulate=args.simulate)
print("started, waiting for the head to come home...")
e10.wait_for_mark(on_pause=lambda: print(
"PAUSE line reached. press Start on the machine."))
print("cycle complete")
elif args.cmd == "reset":
e10.reset_error()
print("fault cleared")
elif args.cmd == "machine":
print(e10.get_machine().hex(" "))
elif args.cmd == "backup":
os.makedirs(args.outdir, exist_ok=True)
count = 0
failed = []
for entry in e10.list_files():
suffix = KIND_NAMES.get(entry["kind"], "kind%s" % entry["kind"])
# one unreadable file must not abandon the whole snapshot
try:
blob = e10.read_file(entry["name"], kind=entry["kind"])
except E10Error as err:
failed.append((entry["name"], str(err)))
print("%-12s %6s FAILED: %s"
% (entry["name"], "", err))
continue
path = os.path.join(
args.outdir, "%s.%s" % (entry["name"], suffix))
with open(path, "wb") as handle:
handle.write(blob)
print("%-12s %6d bytes -> %s%s" % (
entry["name"], len(blob), path,
" [CONTAINS CREDENTIAL]" if is_sensitive(entry["name"])
else ""))
count += 1
print("\n%d files captured, %d failed" % (count, len(failed)))
for name, err in failed:
print(" FAILED %-12s %s" % (name, err))
if __name__ == "__main__":
try:
main()
except E10Fault as fault:
sys.stderr.write("controller fault: status %s\n"
"run 'reset' before marking again\n"
% fault.status.hex(" "))
sys.exit(2)
except E10Error as err:
sys.stderr.write("e10: %s\n" % err)
sys.exit(1)

260
scripts/fake-telesis.py Normal file
View File

@@ -0,0 +1,260 @@
#!/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()

279
scripts/make-e10-pattern.py Normal file
View File

@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Build SIC e10 marking files with DECLARED variables.
WHY THIS EXISTS
The e10's own protocol cannot create a variable. You can build a marking
file over the wire with NEWFILE / INSERTTEXTLINE / SAVEFILE, and you can put
"#(V, V01)" in a text line, but that is only a REFERENCE. The variable has
to be DECLARED, and there is no INSERTVAR command. Proven the hard way: a
file built entirely over the protocol came back with a variable count of
zero, and SETVAR answered "variable not found".
Global variables are numbered 1-10, so #(V, NAME) cannot reach one either.
That left authoring dependent on SIC's GUI, which would have made migrating
a fleet of Telesis patterns a hand operation. This does it from a template
instead.
THE FILE FORMAT, reversed from 28 real files plus one we built over the wire
header
0x00 "MARK_V5.0\\0"
0x0A variable count <- the byte that matters. zero here means
SETVAR will always fail.
0x0B record count
0x0C two bytes, purpose unknown, carried through unchanged
0x0E offset table: 16-bit big-endian file offsets, one per record.
The number of entries is implied - the first entry points at the
first record, so entries = (first_offset - 0x0E) / 2.
variable declaration record
[type][len-6][class][00][name, NUL-padded]
type 0x02 and 0x04 both seen; class 0x50 ('P') and 0x40 ('@') both seen.
Carried through unchanged except for the name, which is rewritten
inside its fixed-width padded field so the record length never moves.
text line record
[X(2)][Y(2)][Z(2)][W(2)][H(2)][2 bytes][textlen+13 (2)][2 bytes]
[0x83][font name, 12 bytes NUL-padded][text]
All coordinates are big-endian tenths of a mm. The field at offset 12
is len(text) + 13 in every file checked, which is what makes rewriting
the text safe.
trailer records
Short fixed records at the end. Carried through untouched.
WHAT THIS DOES
Takes a known-good file as a template, rewrites the variable names and the
text lines, then rebuilds the offset table so the records still line up.
Everything it does not understand is copied through byte for byte, which is
the only honest way to edit a format nobody has documented.
"""
import argparse
import os
import sys
MAGIC = b"MARK_V5.0\x00"
TEXT_HEADER_LEN = 29 # geometry + marker byte + 12-byte font field
TEXT_LEN_OFFSET = 12 # where len(text)+13 lives inside a text record
TEXT_LEN_BIAS = 13
FONT_OFFSET = 17
FONT_FIELD = 12
NAME_OFFSET = 4 # where a variable's name starts in its record
def be16(data, index):
return (data[index] << 8) | data[index + 1]
def pack_be16(value):
return bytes([(value >> 8) & 0xFF, value & 0xFF])
class Pattern:
"""A parsed MARK_V5.0 file: a header, a list of records, nothing hidden."""
def __init__(self, raw):
if not raw.startswith(MAGIC):
raise ValueError("not a MARK_V5.0 file")
self.raw = raw
self.varcount = raw[0x0A]
self.reccount = raw[0x0B]
self.unknown = raw[0x0C:0x0E]
first = be16(raw, 0x0E)
entries = (first - 0x0E) // 2
if entries < 1:
raise ValueError("implausible offset table")
offsets = [be16(raw, 0x0E + 2 * i) for i in range(entries)]
self.records = []
for index, start in enumerate(offsets):
nxt = offsets[index + 1] if index + 1 < len(offsets) else len(raw)
if nxt <= start:
nxt = len(raw)
self.records.append(bytearray(raw[start:nxt]))
# ------------------------------------------------------------ inspection
def is_variable(self, record):
"""A declaration record, identified by its own length arithmetic.
Do NOT match on byte 0 being 0x02 or 0x04 - that is the name LENGTH,
which changes the moment you rename anything, and matching on it makes
a renamed variable invisible to the next edit.
"""
return (len(record) >= NAME_OFFSET + 1
and record[2] in (0x40, 0x50)
and record[3] == 0x00
and len(record) == NAME_OFFSET + record[0] + record[1])
def is_text(self, record):
return (len(record) > FONT_OFFSET
and record[FONT_OFFSET - 1] == 0x83)
def variable_names(self):
out = []
for rec in self.records:
if self.is_variable(rec):
out.append(bytes(rec[NAME_OFFSET:]).split(b"\x00")[0]
.decode("ascii", "replace"))
return out
def variable_sizes(self):
"""Value buffer size per variable - the silent truncation limit."""
return [r[1] for r in self.records if self.is_variable(r)]
def texts(self):
out = []
for rec in self.records:
if self.is_text(rec):
out.append(bytes(rec[TEXT_HEADER_LEN:])
.decode("ascii", "replace"))
return out
# -------------------------------------------------------------- editing
def rename_variable(self, index, new_name, value_size=None):
"""Rewrite a declaration's name.
THE RECORD LAYOUT, and the two length bytes both matter:
[name_len][value_size][class][00][name][value buffer]
SN 02 0a 50 00 "SN" 16 = 4 + 2 + 10
PN 02 0e 50 00 "PN" 20 = 4 + 2 + 14
OF 02 0f 40 00 "OF" 21 = 4 + 2 + 15
TEMP 04 02 40 00 "TEMP" 10 = 4 + 4 + 2
Byte 0 is the length of the NAME. Byte 1 is the size of the VALUE
buffer that follows it, which is the longest string SETVAR can put in
this variable.
Both were found the hard way. Writing a three-character name while byte
0 still said 2 produced a file that pushed cleanly, read back
byte-identical and loaded without complaint - and then SETVAR answered
"variable not found", because the controller had parsed a name of the
wrong length. A silent, entirely plausible-looking failure.
"""
found = [i for i, r in enumerate(self.records) if self.is_variable(r)]
if index >= len(found):
raise ValueError("no variable %d in this template" % index)
position = found[index]
rec = self.records[position]
encoded = new_name.encode("ascii")
if not 1 <= len(encoded) <= 255:
raise ValueError("implausible variable name %r" % new_name)
size = rec[1] if value_size is None else value_size
rebuilt = bytearray(rec[:NAME_OFFSET])
rebuilt[0] = len(encoded)
rebuilt[1] = size
rebuilt += encoded
rebuilt += b"\x00" * size
self.records[position] = rebuilt
def set_text(self, index, text):
"""Replace a text line's content and fix its internal length field."""
found = [i for i, r in enumerate(self.records) if self.is_text(r)]
if index >= len(found):
raise ValueError("no text line %d in this template" % index)
rec = self.records[found[index]]
encoded = text.encode("ascii")
rec[TEXT_HEADER_LEN:] = encoded
rec[TEXT_LEN_OFFSET:TEXT_LEN_OFFSET + 2] = pack_be16(
len(encoded) + TEXT_LEN_BIAS)
def set_position(self, index, x=None, y=None):
"""Move a text line. Coordinates are tenths of a mm."""
found = [i for i, r in enumerate(self.records) if self.is_text(r)]
rec = self.records[found[index]]
if x is not None:
rec[0:2] = pack_be16(x)
if y is not None:
rec[2:4] = pack_be16(y)
# ------------------------------------------------------------- assembly
def build(self):
"""Reassemble, recomputing every offset from the record lengths."""
entries = len(self.records)
table_start = 0x0E
body_start = table_start + 2 * entries
offsets, cursor = [], body_start
for rec in self.records:
offsets.append(cursor)
cursor += len(rec)
out = bytearray(MAGIC)
out.append(self.varcount)
out.append(self.reccount)
out += self.unknown
for off in offsets:
out += pack_be16(off)
for rec in self.records:
out += rec
return bytes(out)
def describe(pattern, label):
print("%s" % label)
sizes = pattern.variable_sizes()
print(" variables declared : %d %s"
% (pattern.varcount,
", ".join("%s(max %d)" % (n, s)
for n, s in zip(pattern.variable_names(), sizes))
or "-"))
print(" records : %d" % len(pattern.records))
for text in pattern.texts():
print(" text line : %r" % text)
def main():
parser = argparse.ArgumentParser(
description="Build an e10 marking file with declared variables.")
parser.add_argument("--template", required=True,
help="a known-good marking file with at least as many"
" variables as you need. NEW.marking has 2.")
parser.add_argument("--var", action="append", default=[],
help="new name for the next variable. repeatable.")
parser.add_argument("--var-size", type=int, default=127,
help="value buffer size for each variable, 1-127."
" THE CONTROLLER TRUNCATES SILENTLY to this and"
" still answers SETVAR ok, so the default is the"
" protocol maximum. Only lower it deliberately.")
parser.add_argument("--line", action="append", default=[],
help="text for the next line. repeatable."
" Use #(V, NAME) to reference a variable.")
parser.add_argument("-o", "--out", help="write the file here")
parser.add_argument("--show", action="store_true",
help="just describe the template and exit")
args = parser.parse_args()
pattern = Pattern(open(args.template, "rb").read())
describe(pattern, "TEMPLATE %s" % args.template)
if args.show:
return
if not 1 <= args.var_size <= 127:
sys.exit("--var-size must be 1..127 (FILE SET VAR carries 1-127 bytes)")
for index, name in enumerate(args.var):
pattern.rename_variable(index, name, value_size=args.var_size)
for index, text in enumerate(args.line):
pattern.set_text(index, text)
built = Pattern(pattern.build())
print()
describe(built, "BUILT")
if args.out:
with open(args.out, "wb") as handle:
handle.write(pattern.build())
print()
print("wrote %s (%d bytes)" % (args.out, len(pattern.build())))
if __name__ == "__main__":
main()

412
scripts/simulate-ednc.py Normal file
View File

@@ -0,0 +1,412 @@
#!/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()