feat(e10): run on Windows as well as Linux
Serial access moves behind open_port(), with a termios backend on POSIX and a
Win32 backend reached through ctypes. Deliberately no pyserial: these tools
have to run on locked-down, air-gapped shopfloor machines where installing a
package is a project of its own.
Verified on a real controller from both a Linux laptop and a Windows 11 PC.
Three things that made this harder than it should have been, all now handled:
- CreateFileW returns a HANDLE, which is 64-bit. Without an explicit restype
ctypes truncates it to c_int. The open then appears to succeed and every
read and write silently operates on a bogus handle, which looks exactly
like a dead cable. Every kernel32 call now declares argtypes and restype.
- Defaulting to a fixed COM number was actively harmful. A wrong-but-real
port opens without error and answers nothing, so defaulting to COM3 while
the adapter was on COM4 produced the same silent symptom. Windows now
auto-picks only when exactly one port looks like a USB virtual COM port,
and otherwise refuses and lists what it found.
- COM10 and above need the \\.\ prefix, which is harmless on COM1-9, so it
is always applied.
Adds two diagnostics, because "no response" has too many possible causes to
guess at:
ports every serial port that actually exists. On Windows this reads
SERIALCOMM from the registry, so a port absent here does not exist
now, whatever Device Manager showed last week.
doctor walks the layers - discovery, open, write, read - and reports which
one fails, including a warning when the configured port is an
onboard one while a USB adapter is present.
Port selection is -d, then $E10_PORT, then the safe auto-pick. Friendlier
errors on both platforms for a missing port or a permissions problem.
This commit is contained in:
@@ -90,15 +90,31 @@ LINK LAYER
|
|||||||
Both ends are DTE (each calls pin 2 RX and pin 3 TX), so the cable must be
|
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.
|
a crossover: 3 to 2, 2 to 3, 5 to 5.
|
||||||
|
|
||||||
Stdlib only, termios - no pyserial on the bench box.
|
RUNS ON LINUX AND WINDOWS, stdlib only - no pyserial. Serial access sits
|
||||||
|
behind open_port(): termios on POSIX, the Win32 API through ctypes on Windows.
|
||||||
|
That choice is deliberate rather than purist: these tools have to run on
|
||||||
|
locked-down, air-gapped shopfloor machines where installing a package is a
|
||||||
|
project of its own.
|
||||||
|
|
||||||
|
Pick the port with -d, or set E10_PORT once:
|
||||||
|
Windows set E10_PORT=COM4 Linux export E10_PORT=/dev/ttyUSB0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import termios
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
# Serial access differs per platform and neither module exists on the other.
|
||||||
|
# termios is POSIX; the Windows path goes through the Win32 API via ctypes so
|
||||||
|
# there is nothing to pip-install on an air-gapped shopfloor PC.
|
||||||
|
if os.name == "nt":
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
termios = None
|
||||||
|
else:
|
||||||
|
import termios
|
||||||
|
|
||||||
# framing
|
# framing
|
||||||
STX = 0x02
|
STX = 0x02
|
||||||
ETX = 0x03
|
ETX = 0x03
|
||||||
@@ -233,6 +249,200 @@ class E10Fault(E10Error):
|
|||||||
% (status.hex(" "), "; ".join(self.reasons)))
|
% (status.hex(" "), "; ".join(self.reasons)))
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PORT_LINUX = "/dev/ttyUSB0"
|
||||||
|
|
||||||
|
|
||||||
|
def default_port():
|
||||||
|
"""Which serial port to talk to, without editing any source.
|
||||||
|
|
||||||
|
Order of precedence:
|
||||||
|
1. the --device / -d argument
|
||||||
|
2. the E10_PORT environment variable
|
||||||
|
3. a per-platform default
|
||||||
|
|
||||||
|
Set E10_PORT once and every tool here picks it up. On Windows that is a
|
||||||
|
COM name as Device Manager shows it; on Linux a device path.
|
||||||
|
|
||||||
|
Windows set E10_PORT=COM4
|
||||||
|
Linux export E10_PORT=/dev/ttyUSB0
|
||||||
|
"""
|
||||||
|
from_env = os.environ.get("E10_PORT")
|
||||||
|
if from_env:
|
||||||
|
return from_env
|
||||||
|
|
||||||
|
# NEVER blind-default to a fixed COM number on Windows. A wrong-but-real
|
||||||
|
# port opens without error and answers nothing, which is indistinguishable
|
||||||
|
# from a dead cable - it cost a debugging round trip when the default was
|
||||||
|
# COM3 (an onboard \Device\Serial0) while the adapter was COM4.
|
||||||
|
# Prefer a port that is clearly a USB virtual COM port; if that is
|
||||||
|
# ambiguous, say so rather than guessing.
|
||||||
|
if os.name == "nt":
|
||||||
|
ports = list_ports()
|
||||||
|
virtual = [name for name, detail in ports
|
||||||
|
if "VCP" in detail.upper() or "USB" in detail.upper()]
|
||||||
|
if len(virtual) == 1:
|
||||||
|
return virtual[0]
|
||||||
|
if not ports:
|
||||||
|
raise E10Error(
|
||||||
|
"no serial ports on this machine. Plug the USB-serial adapter"
|
||||||
|
" in, then run: e10-client.py ports")
|
||||||
|
raise E10Error(
|
||||||
|
"cannot tell which port to use - set E10_PORT or pass -d.\n"
|
||||||
|
" available: %s\n"
|
||||||
|
" the adapter is normally the \\Device\\VCP* one.\n"
|
||||||
|
" example: set E10_PORT=%s"
|
||||||
|
% (", ".join("%s (%s)" % (n, d or "?") for n, d in ports),
|
||||||
|
(virtual or [ports[0][0]])[0]))
|
||||||
|
return DEFAULT_PORT_LINUX
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def list_ports():
|
||||||
|
"""Every serial port this machine actually has, with a description.
|
||||||
|
|
||||||
|
Windows: read HKLM\\HARDWARE\\DEVICEMAP\\SERIALCOMM, which lists only
|
||||||
|
real, present serial devices - so a port missing from here does not exist
|
||||||
|
right now, whatever Device Manager showed last week.
|
||||||
|
|
||||||
|
Linux: glob the usual device nodes.
|
||||||
|
"""
|
||||||
|
found = []
|
||||||
|
if os.name == "nt":
|
||||||
|
import winreg
|
||||||
|
try:
|
||||||
|
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
||||||
|
r"HARDWARE\DEVICEMAP\SERIALCOMM")
|
||||||
|
except OSError:
|
||||||
|
return found
|
||||||
|
index = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
name, value, _ = winreg.EnumValue(key, index)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
found.append((value, name))
|
||||||
|
index += 1
|
||||||
|
winreg.CloseKey(key)
|
||||||
|
else:
|
||||||
|
import glob
|
||||||
|
for path in sorted(glob.glob("/dev/ttyUSB*") +
|
||||||
|
glob.glob("/dev/ttyACM*")):
|
||||||
|
found.append((path, _linux_detail(path)))
|
||||||
|
# ttyS0..ttyS31 exist as 8250 placeholders on almost every Linux
|
||||||
|
# machine and are nearly all phantom - every discriminator I tried
|
||||||
|
# (a /sys device node, a bound driver) is true for all 32. Listing
|
||||||
|
# them buries the one port that matters under 32 lines of noise, and
|
||||||
|
# a USB adapter is never one of them, so they are summarised instead.
|
||||||
|
legacy = sorted(glob.glob("/dev/ttyS*"))
|
||||||
|
if legacy:
|
||||||
|
found.append(("(%d x /dev/ttyS*)" % len(legacy),
|
||||||
|
"legacy onboard ports, rarely the adapter"))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_detail(path):
|
||||||
|
"""Best-effort description of a USB serial adapter."""
|
||||||
|
base = os.path.basename(path)
|
||||||
|
try:
|
||||||
|
link = os.path.realpath("/sys/class/tty/%s/device" % base)
|
||||||
|
for _ in range(4):
|
||||||
|
link = os.path.dirname(link)
|
||||||
|
product = os.path.join(link, "product")
|
||||||
|
if os.path.exists(product):
|
||||||
|
with open(product) as handle:
|
||||||
|
return handle.read().strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def doctor(device, timeout=3.0):
|
||||||
|
"""Work out why a port is silent, one layer at a time."""
|
||||||
|
print("PORT DISCOVERY")
|
||||||
|
ports = list_ports()
|
||||||
|
if not ports:
|
||||||
|
print(" no serial ports found on this machine at all")
|
||||||
|
for name, detail in ports:
|
||||||
|
mark = " <-- configured" if name.upper() == (device or "").upper() else ""
|
||||||
|
print(" %-10s %s%s" % (name, detail, mark))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("CONFIGURED PORT: %s" % device)
|
||||||
|
if os.name == "nt" and ports:
|
||||||
|
names = [n.upper() for n, _ in ports]
|
||||||
|
if device.upper() not in names:
|
||||||
|
print(" *** NOT IN THE LIST ABOVE ***")
|
||||||
|
print(" This port does not exist. Set E10_PORT to one that does:")
|
||||||
|
print(" set E10_PORT=%s" % ports[0][0])
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.name == "nt" and ports:
|
||||||
|
detail = dict((n.upper(), d) for n, d in ports).get(
|
||||||
|
(device or "").upper(), "")
|
||||||
|
virtual = [n for n, d in ports
|
||||||
|
if "VCP" in d.upper() or "USB" in d.upper()]
|
||||||
|
if virtual and device.upper() not in [v.upper() for v in virtual]:
|
||||||
|
print(" WARNING: %s is %s, which is not a USB adapter."
|
||||||
|
% (device, detail or "an onboard port"))
|
||||||
|
print(" A port like this opens fine and answers nothing.")
|
||||||
|
print(" The adapter is probably: %s" % ", ".join(virtual))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("OPENING")
|
||||||
|
try:
|
||||||
|
port = open_port(device)
|
||||||
|
except E10Error as err:
|
||||||
|
print(" FAILED: %s" % err)
|
||||||
|
return
|
||||||
|
print(" opened ok")
|
||||||
|
if os.name == "nt":
|
||||||
|
print(" handle: 0x%X (a plausible handle is small and non-zero;"
|
||||||
|
" 0xFFFFFFFFFFFFFFFF means the open really failed)"
|
||||||
|
% port.handle)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("WRITING")
|
||||||
|
probe = b"GETDATETIME\n"
|
||||||
|
try:
|
||||||
|
port.write(probe)
|
||||||
|
print(" wrote %d bytes: %r" % (len(probe), probe))
|
||||||
|
except E10Error as err:
|
||||||
|
print(" FAILED: %s" % err)
|
||||||
|
port.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("READING for %.1fs" % timeout)
|
||||||
|
got = bytearray()
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < end:
|
||||||
|
chunk = port.read(256)
|
||||||
|
if chunk:
|
||||||
|
got += chunk
|
||||||
|
else:
|
||||||
|
time.sleep(0.01)
|
||||||
|
port.close()
|
||||||
|
|
||||||
|
if got:
|
||||||
|
print(" %d bytes: %s" % (len(got), bytes(got).hex(" ")))
|
||||||
|
print(" as text : %r" % bytes(got))
|
||||||
|
print()
|
||||||
|
print(" THE LINK WORKS.")
|
||||||
|
else:
|
||||||
|
print(" nothing came back.")
|
||||||
|
print()
|
||||||
|
print(" The port opened and the write succeeded, so the software and")
|
||||||
|
print(" the driver are fine. That leaves, in order:")
|
||||||
|
print(" 1. WRONG PORT - it opened, but is it the adapter? A")
|
||||||
|
print(" Bluetooth or virtual COM port opens happily and never")
|
||||||
|
print(" answers. Unplug the adapter, re-run, and see which")
|
||||||
|
print(" entry disappears from the list above.")
|
||||||
|
print(" 2. CABLE - both ends are DTE, so it must be a CROSSOVER.")
|
||||||
|
print(" Meter it: pin 2 at one end to pin 3 at the other.")
|
||||||
|
print(" 3. WRONG SOCKET on the controller - use HOST, not SERIAL.")
|
||||||
|
print(" 4. Controller powered off, or not finished booting.")
|
||||||
|
|
||||||
|
|
||||||
def encode_name(name):
|
def encode_name(name):
|
||||||
"""Pack a file name into the 12-byte name field the controller expects.
|
"""Pack a file name into the 12-byte name field the controller expects.
|
||||||
|
|
||||||
@@ -256,6 +466,234 @@ def decode_name(raw):
|
|||||||
return raw[:NAME_FIELD_LEN].split(b"\x00", 1)[0].decode("ascii", "replace")
|
return raw[:NAME_FIELD_LEN].split(b"\x00", 1)[0].decode("ascii", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
class _PosixPort:
|
||||||
|
"""Serial port through termios. Raw, 8-N-1, no flow control."""
|
||||||
|
|
||||||
|
def __init__(self, device, baud=9600):
|
||||||
|
try:
|
||||||
|
self.fd = os.open(device,
|
||||||
|
os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise E10Error(
|
||||||
|
"no such port: %s. Is the USB-serial adapter plugged in?"
|
||||||
|
" Check with: ls /dev/ttyUSB*" % device)
|
||||||
|
except PermissionError:
|
||||||
|
raise E10Error(
|
||||||
|
"permission denied on %s. Add yourself to the dialout group"
|
||||||
|
" (sudo usermod -aG dialout $USER, then log out and in), or"
|
||||||
|
" run under: sg dialout -c '...'" % device)
|
||||||
|
speed = getattr(termios, "B%d" % baud)
|
||||||
|
attrs = termios.tcgetattr(self.fd)
|
||||||
|
cc = list(attrs[6])
|
||||||
|
cc[termios.VMIN] = 0
|
||||||
|
cc[termios.VTIME] = 0
|
||||||
|
# raw: no canonical mode, echo, signals or translation. CLOCAL matters
|
||||||
|
# because the e10 HOST port has no handshake pins, so waiting on
|
||||||
|
# carrier detect would hang forever.
|
||||||
|
termios.tcsetattr(self.fd, termios.TCSANOW,
|
||||||
|
[0, 0,
|
||||||
|
termios.CS8 | termios.CREAD | termios.CLOCAL,
|
||||||
|
0, speed, speed, cc])
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
def read(self, count):
|
||||||
|
try:
|
||||||
|
return os.read(self.fd, count)
|
||||||
|
except BlockingIOError:
|
||||||
|
return b""
|
||||||
|
except OSError:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
offset = 0
|
||||||
|
while offset < len(data):
|
||||||
|
offset += os.write(self.fd, data[offset:])
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
try:
|
||||||
|
termios.tcflush(self.fd, termios.TCIOFLUSH)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
os.close(self.fd)
|
||||||
|
|
||||||
|
|
||||||
|
class _WindowsPort:
|
||||||
|
r"""Serial port through the Win32 API, reached with ctypes.
|
||||||
|
|
||||||
|
Deliberately no pyserial: these tools have to run on locked-down,
|
||||||
|
air-gapped shopfloor machines where installing a package is a project of
|
||||||
|
its own. Everything here is in the standard library.
|
||||||
|
|
||||||
|
Port naming: COM10 and above REQUIRE the \\.\ prefix, and it is harmless
|
||||||
|
on COM1-COM9, so it is always applied. Without it COM10 fails to open with
|
||||||
|
a file-not-found error that looks nothing like the real cause.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GENERIC_READ = 0x80000000
|
||||||
|
GENERIC_WRITE = 0x40000000
|
||||||
|
OPEN_EXISTING = 3
|
||||||
|
INVALID_HANDLE = (1 << 64) - 1 # 0xFFFF...FF, what CreateFileW returns
|
||||||
|
PURGE_RXCLEAR = 0x0008
|
||||||
|
PURGE_TXCLEAR = 0x0004
|
||||||
|
MAXDWORD = 0xFFFFFFFF
|
||||||
|
|
||||||
|
def __init__(self, device, baud=9600):
|
||||||
|
self.k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
self._declare()
|
||||||
|
name = device if device.startswith("\\\\") else "\\\\.\\" + device
|
||||||
|
|
||||||
|
self.handle = self.k32.CreateFileW(
|
||||||
|
ctypes.c_wchar_p(name),
|
||||||
|
self.GENERIC_READ | self.GENERIC_WRITE,
|
||||||
|
0, # no sharing - a serial port is exclusive
|
||||||
|
None,
|
||||||
|
self.OPEN_EXISTING,
|
||||||
|
0, # synchronous
|
||||||
|
None)
|
||||||
|
if self.handle is None or self.handle == self.INVALID_HANDLE:
|
||||||
|
code = ctypes.get_last_error()
|
||||||
|
hint = ""
|
||||||
|
if code == 2:
|
||||||
|
hint = (" - no such port. Check Device Manager under"
|
||||||
|
" 'Ports (COM & LPT)'.")
|
||||||
|
elif code == 5:
|
||||||
|
hint = (" - access denied. Another program has the port open;"
|
||||||
|
" close any terminal or vendor software using it.")
|
||||||
|
raise E10Error("cannot open %s: Windows error %d%s"
|
||||||
|
% (device, code, hint))
|
||||||
|
|
||||||
|
self._configure(baud)
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
def _declare(self):
|
||||||
|
"""Declare argument and return types for every kernel32 call.
|
||||||
|
|
||||||
|
THIS IS NOT OPTIONAL ON 64-BIT WINDOWS. A HANDLE is a pointer, so it is
|
||||||
|
64 bits, but an undeclared ctypes function returns c_int - 32 bits. The
|
||||||
|
handle from CreateFileW is then silently TRUNCATED. It does not match
|
||||||
|
INVALID_HANDLE_VALUE, so the open appears to succeed, and every
|
||||||
|
subsequent read and write operates on a handle that is not the port and
|
||||||
|
returns nothing at all.
|
||||||
|
|
||||||
|
The symptom is a tool that runs, reports no error, and never receives a
|
||||||
|
byte - which is indistinguishable from a dead cable, and cost a round
|
||||||
|
trip to diagnose.
|
||||||
|
"""
|
||||||
|
k = self.k32
|
||||||
|
k.CreateFileW.restype = wintypes.HANDLE
|
||||||
|
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD,
|
||||||
|
wintypes.DWORD, wintypes.LPVOID,
|
||||||
|
wintypes.DWORD, wintypes.DWORD,
|
||||||
|
wintypes.HANDLE]
|
||||||
|
k.CloseHandle.restype = wintypes.BOOL
|
||||||
|
k.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||||
|
k.GetCommState.restype = wintypes.BOOL
|
||||||
|
k.GetCommState.argtypes = [wintypes.HANDLE, wintypes.LPVOID]
|
||||||
|
k.SetCommState.restype = wintypes.BOOL
|
||||||
|
k.SetCommState.argtypes = [wintypes.HANDLE, wintypes.LPVOID]
|
||||||
|
k.SetCommTimeouts.restype = wintypes.BOOL
|
||||||
|
k.SetCommTimeouts.argtypes = [wintypes.HANDLE, wintypes.LPVOID]
|
||||||
|
k.PurgeComm.restype = wintypes.BOOL
|
||||||
|
k.PurgeComm.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||||
|
k.ReadFile.restype = wintypes.BOOL
|
||||||
|
k.ReadFile.argtypes = [wintypes.HANDLE, wintypes.LPVOID,
|
||||||
|
wintypes.DWORD, wintypes.LPDWORD,
|
||||||
|
wintypes.LPVOID]
|
||||||
|
k.WriteFile.restype = wintypes.BOOL
|
||||||
|
k.WriteFile.argtypes = [wintypes.HANDLE, wintypes.LPCVOID,
|
||||||
|
wintypes.DWORD, wintypes.LPDWORD,
|
||||||
|
wintypes.LPVOID]
|
||||||
|
k.ClearCommError.restype = wintypes.BOOL
|
||||||
|
k.ClearCommError.argtypes = [wintypes.HANDLE, wintypes.LPDWORD,
|
||||||
|
wintypes.LPVOID]
|
||||||
|
|
||||||
|
def _configure(self, baud):
|
||||||
|
class DCB(ctypes.Structure):
|
||||||
|
_fields_ = [("DCBlength", wintypes.DWORD),
|
||||||
|
("BaudRate", wintypes.DWORD),
|
||||||
|
("bitfield", wintypes.DWORD),
|
||||||
|
("wReserved", wintypes.WORD),
|
||||||
|
("XonLim", wintypes.WORD),
|
||||||
|
("XoffLim", wintypes.WORD),
|
||||||
|
("ByteSize", ctypes.c_ubyte),
|
||||||
|
("Parity", ctypes.c_ubyte),
|
||||||
|
("StopBits", ctypes.c_ubyte),
|
||||||
|
("XonChar", ctypes.c_char),
|
||||||
|
("XoffChar", ctypes.c_char),
|
||||||
|
("ErrorChar", ctypes.c_char),
|
||||||
|
("EofChar", ctypes.c_char),
|
||||||
|
("EvtChar", ctypes.c_char),
|
||||||
|
("wReserved1", wintypes.WORD)]
|
||||||
|
|
||||||
|
class COMMTIMEOUTS(ctypes.Structure):
|
||||||
|
_fields_ = [("ReadIntervalTimeout", wintypes.DWORD),
|
||||||
|
("ReadTotalTimeoutMultiplier", wintypes.DWORD),
|
||||||
|
("ReadTotalTimeoutConstant", wintypes.DWORD),
|
||||||
|
("WriteTotalTimeoutMultiplier", wintypes.DWORD),
|
||||||
|
("WriteTotalTimeoutConstant", wintypes.DWORD)]
|
||||||
|
|
||||||
|
dcb = DCB()
|
||||||
|
dcb.DCBlength = ctypes.sizeof(DCB)
|
||||||
|
if not self.k32.GetCommState(self.handle, ctypes.byref(dcb)):
|
||||||
|
raise E10Error("GetCommState failed: Windows error %d"
|
||||||
|
% ctypes.get_last_error())
|
||||||
|
dcb.BaudRate = baud
|
||||||
|
dcb.ByteSize = 8
|
||||||
|
dcb.Parity = 0 # NOPARITY
|
||||||
|
dcb.StopBits = 0 # ONESTOPBIT
|
||||||
|
# bit 0 is fBinary. Every other flag stays clear, which disables
|
||||||
|
# XON/XOFF, CTS/DSR handshaking, and leaves DTR and RTS disabled -
|
||||||
|
# correct here, because the HOST port has no handshake pins at all.
|
||||||
|
dcb.bitfield = 0x0001
|
||||||
|
if not self.k32.SetCommState(self.handle, ctypes.byref(dcb)):
|
||||||
|
raise E10Error("SetCommState failed: Windows error %d"
|
||||||
|
% ctypes.get_last_error())
|
||||||
|
|
||||||
|
# Return whatever has arrived, immediately, and never block - the
|
||||||
|
# caller runs its own polling loop with its own deadline.
|
||||||
|
timeouts = COMMTIMEOUTS(self.MAXDWORD, 0, 0, 0, 1000)
|
||||||
|
if not self.k32.SetCommTimeouts(self.handle, ctypes.byref(timeouts)):
|
||||||
|
raise E10Error("SetCommTimeouts failed: Windows error %d"
|
||||||
|
% ctypes.get_last_error())
|
||||||
|
|
||||||
|
def read(self, count):
|
||||||
|
buf = ctypes.create_string_buffer(count)
|
||||||
|
got = wintypes.DWORD(0)
|
||||||
|
if not self.k32.ReadFile(self.handle, buf, count,
|
||||||
|
ctypes.byref(got), None):
|
||||||
|
return b""
|
||||||
|
return buf.raw[:got.value]
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
written = wintypes.DWORD(0)
|
||||||
|
offset = 0
|
||||||
|
while offset < len(data):
|
||||||
|
chunk = ctypes.create_string_buffer(bytes(data[offset:]))
|
||||||
|
size = len(data) - offset
|
||||||
|
if not self.k32.WriteFile(self.handle, chunk, size,
|
||||||
|
ctypes.byref(written), None):
|
||||||
|
raise E10Error("WriteFile failed: Windows error %d"
|
||||||
|
% ctypes.get_last_error())
|
||||||
|
if not written.value:
|
||||||
|
raise E10Error("WriteFile wrote nothing")
|
||||||
|
offset += written.value
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
self.k32.PurgeComm(self.handle,
|
||||||
|
self.PURGE_RXCLEAR | self.PURGE_TXCLEAR)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.k32.CloseHandle(self.handle)
|
||||||
|
|
||||||
|
|
||||||
|
def open_port(device, baud=9600):
|
||||||
|
"""Open a serial port on whichever platform we are running on."""
|
||||||
|
return (_WindowsPort(device, baud) if os.name == "nt"
|
||||||
|
else _PosixPort(device, baud))
|
||||||
|
|
||||||
|
|
||||||
class E10:
|
class E10:
|
||||||
"""One serial connection to an e10 controller.
|
"""One serial connection to an e10 controller.
|
||||||
|
|
||||||
@@ -263,55 +701,35 @@ class E10:
|
|||||||
binary protocol; the text_* helpers exist for bench work only.
|
binary protocol; the text_* helpers exist for bench work only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, device="/dev/ttyUSB0", timeout=2.0, checksum=False,
|
def __init__(self, device=None, timeout=2.0, checksum=False,
|
||||||
debug=False):
|
debug=False):
|
||||||
|
device = device or default_port()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.checksum = checksum
|
self.checksum = checksum
|
||||||
self.debug = debug
|
self.debug = debug
|
||||||
self.fd = None
|
self.port = None
|
||||||
self._var_sizes = {}
|
self._var_sizes = {}
|
||||||
self._loaded = None
|
self._loaded = None
|
||||||
|
|
||||||
# ---------------------------------------------------------------- port
|
# ---------------------------------------------------------------- port
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
self.fd = os.open(self.device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
self.port = open_port(self.device)
|
||||||
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
|
# Let anything still in flight from a previous session arrive, then
|
||||||
# throw it away. Without this the first transaction reads someone
|
# throw it away. Without this the first transaction reads someone
|
||||||
# else's tail.
|
# else's tail - which happened repeatedly during development.
|
||||||
settle = time.monotonic() + 0.25
|
settle = time.monotonic() + 0.25
|
||||||
while time.monotonic() < settle:
|
while time.monotonic() < settle:
|
||||||
try:
|
if not self.port.read(256):
|
||||||
if not os.read(self.fd, 256):
|
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
except BlockingIOError:
|
self.port.flush()
|
||||||
time.sleep(0.01)
|
|
||||||
termios.tcflush(self.fd, termios.TCIOFLUSH)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self.fd is not None:
|
if self.port is not None:
|
||||||
os.close(self.fd)
|
self.port.close()
|
||||||
self.fd = None
|
self.port = None
|
||||||
self._var_sizes = {}
|
self._var_sizes = {}
|
||||||
self._loaded = None
|
self._loaded = None
|
||||||
|
|
||||||
@@ -331,25 +749,17 @@ class E10:
|
|||||||
Line noise accumulates between transactions and would otherwise be
|
Line noise accumulates between transactions and would otherwise be
|
||||||
read back as the front of the next reply.
|
read back as the front of the next reply.
|
||||||
"""
|
"""
|
||||||
try:
|
self.port.flush()
|
||||||
termios.tcflush(self.fd, termios.TCIFLUSH)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _write(self, data):
|
def _write(self, data):
|
||||||
self._flush_input()
|
self._flush_input()
|
||||||
self._log("TX", data)
|
self._log("TX", data)
|
||||||
offset = 0
|
self.port.write(data)
|
||||||
while offset < len(data):
|
|
||||||
offset += os.write(self.fd, data[offset:])
|
|
||||||
|
|
||||||
def _read_byte(self, deadline):
|
def _read_byte(self, deadline):
|
||||||
"""One byte, or None once the deadline passes."""
|
"""One byte, or None once the deadline passes."""
|
||||||
while True:
|
while True:
|
||||||
try:
|
chunk = self.port.read(1)
|
||||||
chunk = os.read(self.fd, 1)
|
|
||||||
except BlockingIOError:
|
|
||||||
chunk = b""
|
|
||||||
if chunk:
|
if chunk:
|
||||||
return chunk[0]
|
return chunk[0]
|
||||||
if time.monotonic() >= deadline:
|
if time.monotonic() >= deadline:
|
||||||
@@ -690,7 +1100,11 @@ class E10:
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Bench client for a SIC Marking e10 controller.")
|
description="Bench client for a SIC Marking e10 controller.")
|
||||||
parser.add_argument("-d", "--device", default="/dev/ttyUSB0")
|
parser.add_argument("-d", "--device", default=None,
|
||||||
|
help="serial port, e.g. COM4 or"
|
||||||
|
" /dev/ttyUSB0. Defaults to $E10_PORT; on Windows"
|
||||||
|
" it will auto-pick a single USB virtual COM port"
|
||||||
|
" rather than guess.")
|
||||||
parser.add_argument("-t", "--timeout", type=float, default=2.0)
|
parser.add_argument("-t", "--timeout", type=float, default=2.0)
|
||||||
parser.add_argument("--checksum", action="store_true",
|
parser.add_argument("--checksum", action="store_true",
|
||||||
help="enable the XOR checksum form of the framing")
|
help="enable the XOR checksum form of the framing")
|
||||||
@@ -699,6 +1113,9 @@ def main():
|
|||||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
sub.add_parser("version", help="GETVERSION over text. first contact test.")
|
sub.add_parser("version", help="GETVERSION over text. first contact test.")
|
||||||
|
sub.add_parser("ports", help="list the serial ports this machine has")
|
||||||
|
sub.add_parser("doctor",
|
||||||
|
help="diagnose a silent port, one layer at a time")
|
||||||
|
|
||||||
p_text = sub.add_parser("text", help="send one raw text protocol line")
|
p_text = sub.add_parser("text", help="send one raw text protocol line")
|
||||||
p_text.add_argument("line")
|
p_text.add_argument("line")
|
||||||
@@ -736,6 +1153,15 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# these two run without opening a session
|
||||||
|
if args.cmd == "ports":
|
||||||
|
for name, detail in list_ports():
|
||||||
|
print(" %-10s %s" % (name, detail))
|
||||||
|
return
|
||||||
|
if args.cmd == "doctor":
|
||||||
|
doctor(args.device or default_port(), timeout=args.timeout)
|
||||||
|
return
|
||||||
|
|
||||||
with E10(args.device, timeout=args.timeout, checksum=args.checksum,
|
with E10(args.device, timeout=args.timeout, checksum=args.checksum,
|
||||||
debug=args.debug) as e10:
|
debug=args.debug) as e10:
|
||||||
if args.cmd == "version":
|
if args.cmd == "version":
|
||||||
|
|||||||
@@ -302,8 +302,10 @@ def main():
|
|||||||
help="operator input, in prompt order. repeatable.")
|
help="operator input, in prompt order. repeatable.")
|
||||||
parser.add_argument("--no-clear", action="store_true",
|
parser.add_argument("--no-clear", action="store_true",
|
||||||
help="skip the clear-fields pass")
|
help="skip the clear-fields pass")
|
||||||
parser.add_argument("--e10", metavar="DEVICE",
|
parser.add_argument("--e10", metavar="DEVICE", nargs="?", const="",
|
||||||
help="also drive a real e10 on this serial device")
|
help="also drive a real e10. Give a port, or pass the"
|
||||||
|
" flag bare to use $E10_PORT / the platform"
|
||||||
|
" default.")
|
||||||
parser.add_argument("--pattern",
|
parser.add_argument("--pattern",
|
||||||
help="override the pattern name from the ^ line."
|
help="override the pattern name from the ^ line."
|
||||||
" Useful when the e10 file is named differently"
|
" Useful when the e10 file is named differently"
|
||||||
@@ -383,8 +385,9 @@ def drive_e10(seq, args):
|
|||||||
e10mod = importlib.util.module_from_spec(spec)
|
e10mod = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(e10mod)
|
spec.loader.exec_module(e10mod)
|
||||||
|
|
||||||
print("\n DRIVING THE REAL CONTROLLER on %s" % args.e10)
|
port = args.e10 or e10mod.default_port()
|
||||||
with e10mod.E10(args.e10, timeout=5.0) as dev:
|
print("\n DRIVING THE REAL CONTROLLER on %s" % port)
|
||||||
|
with e10mod.E10(port, timeout=5.0) as dev:
|
||||||
for label, raw in seq:
|
for label, raw in seq:
|
||||||
type_char, data = decode_frame(raw)
|
type_char, data = decode_frame(raw)
|
||||||
action, payload = translate(type_char, data)
|
action, payload = translate(type_char, data)
|
||||||
|
|||||||
Reference in New Issue
Block a user