#!/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 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] ... [ETX] with checksum: [STX][VER] ... [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] ... [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)