You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

359 lines
12 KiB

  1. #!/usr/bin/python
  2. # Jim Paris <jim@jtan.com>
  3. # Simple terminal program for serial devices. Supports setting
  4. # baudrates and simple LF->CRLF mapping on input, and basic
  5. # flow control, but nothing fancy.
  6. # ^C quits. There is no escaping, so you can't currently send this
  7. # character to the remote host. Piping input or output should work.
  8. # Supports multiple serial devices simultaneously. When using more
  9. # than one, each device's output is in a different color. Input
  10. # is directed to the first device, or can be sent to all devices
  11. # with --all.
  12. import sys
  13. import os
  14. import serial
  15. import threading
  16. import traceback
  17. import time
  18. import signal
  19. import fcntl
  20. # Need OS-specific method for getting keyboard input.
  21. if os.name == 'nt':
  22. import msvcrt
  23. class Console:
  24. def __init__(self):
  25. self.tty = True
  26. def cleanup(self):
  27. pass
  28. def getkey(self):
  29. start = time.time()
  30. while True:
  31. z = msvcrt.getch()
  32. if z == '\0' or z == '\xe0': # function keys
  33. msvcrt.getch()
  34. else:
  35. if z == '\r':
  36. return '\n'
  37. return z
  38. if (time.time() - start) > 0.1:
  39. return None
  40. elif os.name == 'posix':
  41. import termios, select
  42. class Console:
  43. def __init__(self):
  44. self.fd = sys.stdin.fileno()
  45. if os.isatty(self.fd):
  46. self.tty = True
  47. self.old = termios.tcgetattr(self.fd)
  48. tc = termios.tcgetattr(self.fd)
  49. tc[3] = tc[3] & ~termios.ICANON & ~termios.ECHO & ~termios.ISIG
  50. tc[6][termios.VMIN] = 1
  51. tc[6][termios.VTIME] = 0
  52. termios.tcsetattr(self.fd, termios.TCSANOW, tc)
  53. else:
  54. self.tty = False
  55. fl = fcntl.fcntl(self.fd, fcntl.F_GETFL)
  56. fcntl.fcntl(self.fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
  57. def cleanup(self):
  58. if self.tty:
  59. termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.old)
  60. def getkey(self):
  61. # Return -1 if we don't get input in 0.1 seconds, so that
  62. # the main code can check the "alive" flag and respond to SIGINT.
  63. [r, w, x] = select.select([self.fd], [], [self.fd], 0.1)
  64. if r:
  65. return os.read(self.fd, 65536)
  66. elif x:
  67. return ''
  68. else:
  69. return None
  70. else:
  71. raise ("Sorry, no terminal implementation for your platform (%s) "
  72. "available." % sys.platform)
  73. class JimtermColor(object):
  74. def __init__(self):
  75. self.setup(1)
  76. def setup(self, total):
  77. if total > 1:
  78. self.codes = [
  79. "\x1b[1;36m", # cyan
  80. "\x1b[1;33m", # yellow
  81. "\x1b[1;35m", # magenta
  82. "\x1b[1;31m", # red
  83. "\x1b[1;32m", # green
  84. "\x1b[1;34m", # blue
  85. "\x1b[1;37m", # white
  86. ]
  87. self.reset = "\x1b[0m"
  88. else:
  89. self.codes = [""]
  90. self.reset = ""
  91. def code(self, n):
  92. return self.codes[n % len(self.codes)]
  93. class Jimterm:
  94. """Normal interactive terminal"""
  95. def __init__(self,
  96. serials,
  97. suppress_write_bytes = None,
  98. suppress_read_firstnull = True,
  99. transmit_all = False,
  100. add_cr = False,
  101. raw = True,
  102. color = True):
  103. self.color = JimtermColor()
  104. if color:
  105. self.color.setup(len(serials))
  106. self.serials = serials
  107. self.suppress_write_bytes = suppress_write_bytes
  108. self.suppress_read_firstnull = suppress_read_firstnull
  109. self.last_color = ""
  110. self.threads = []
  111. self.transmit_all = transmit_all
  112. self.add_cr = add_cr
  113. self.raw = raw
  114. def print_header(self, nodes, bauds, output = sys.stdout, piped = False):
  115. for (n, (node, baud)) in enumerate(zip(nodes, bauds)):
  116. output.write(self.color.code(n)
  117. + node + ", " + str(baud) + " baud"
  118. + self.color.reset + "\n")
  119. if not piped:
  120. output.write("^C to exit\n")
  121. output.write("----------\n")
  122. output.flush()
  123. def start(self):
  124. self.alive = True
  125. # serial->console, all devices
  126. for (n, serial) in enumerate(self.serials):
  127. self.threads.append(threading.Thread(
  128. target = self.reader,
  129. args = (serial, self.color.code(n))
  130. ))
  131. # console->serial
  132. self.console = Console()
  133. self.threads.append(threading.Thread(target = self.writer))
  134. # start all threads
  135. for thread in self.threads:
  136. thread.daemon = True
  137. thread.start()
  138. def stop(self):
  139. self.alive = False
  140. def join(self):
  141. for thread in self.threads:
  142. while thread.isAlive():
  143. thread.join(0.1)
  144. def reader(self, serial, color):
  145. """loop and copy serial->console"""
  146. first = True
  147. try:
  148. while self.alive:
  149. data = serial.read(1)
  150. if not data:
  151. continue
  152. # don't print a NULL if it's the first character we
  153. # read. This hides startup/port-opening glitches with
  154. # some serial devices.
  155. if self.suppress_read_firstnull and first and data == '\0':
  156. first = False
  157. continue
  158. first = False
  159. if color != self.last_color:
  160. self.last_color = color
  161. sys.stdout.write(color)
  162. if (self.raw or
  163. (ord(data) >= 32 and ord(data) < 128) or
  164. data == '\r' or data == '\n' or data == '\t'):
  165. if self.add_cr and data == '\n':
  166. sys.stdout.write('\r' + data)
  167. else:
  168. sys.stdout.write(data)
  169. else:
  170. sys.stdout.write('\\x'+("0"+hex(ord(data))[2:])[-2:])
  171. sys.stdout.flush()
  172. except Exception as e:
  173. sys.stdout.write(color)
  174. sys.stdout.flush()
  175. traceback.print_exc()
  176. sys.stdout.write(self.color.reset)
  177. sys.stdout.flush()
  178. self.console.cleanup()
  179. os._exit(1)
  180. def writer(self):
  181. """loop and copy console->serial until ^C"""
  182. try:
  183. while self.alive:
  184. try:
  185. c = self.console.getkey()
  186. except KeyboardInterrupt:
  187. self.stop()
  188. return
  189. if c is None:
  190. # No input, try again.
  191. continue
  192. elif self.console.tty and '\x03' in c:
  193. # Try to catch ^C that didn't trigger KeyboardInterrupt
  194. self.stop()
  195. return
  196. elif c == '':
  197. # Probably EOF on input. Wait a tiny bit so we can
  198. # flush the remaining input, then stop.
  199. time.sleep(0.25)
  200. self.stop()
  201. return
  202. else:
  203. # Remove bytes we don't want to send
  204. if self.suppress_write_bytes is not None:
  205. c = c.translate(None, self.suppress_write_bytes)
  206. # Send character
  207. if self.transmit_all:
  208. for serial in self.serials:
  209. serial.write(c)
  210. else:
  211. self.serials[0].write(c)
  212. except Exception as e:
  213. sys.stdout.write(self.color.reset)
  214. sys.stdout.flush()
  215. traceback.print_exc()
  216. self.console.cleanup()
  217. os._exit(1)
  218. def run(self):
  219. # Set all serial port timeouts to 0.1 sec
  220. saved_timeouts = []
  221. for serial in self.serials:
  222. saved_timeouts.append(serial.timeout)
  223. serial.timeout = 0.1
  224. # Work around https://sourceforge.net/p/pyserial/bugs/151/
  225. saved_writeTimeouts = []
  226. for serial in self.serials:
  227. saved_writeTimeouts.append(serial.writeTimeout)
  228. serial.writeTimeout = 1000000
  229. # Handle SIGINT gracefully
  230. signal.signal(signal.SIGINT, lambda *args: self.stop())
  231. # Go
  232. self.start()
  233. self.join()
  234. # Restore serial port timeouts
  235. for (serial, saved) in zip(self.serials, saved_timeouts):
  236. serial.timeout = saved
  237. for (serial, saved) in zip(self.serials, saved_writeTimeouts):
  238. serial.writeTimeout = saved
  239. # Cleanup
  240. print self.color.reset # and a newline
  241. self.console.cleanup()
  242. if __name__ == "__main__":
  243. import argparse
  244. import re
  245. formatter = argparse.ArgumentDefaultsHelpFormatter
  246. description = ("Simple serial terminal that supports multiple devices. "
  247. "If more than one device is specified, device output is "
  248. "shown in varying colors. All input goes to the "
  249. "first device.")
  250. parser = argparse.ArgumentParser(description = description,
  251. formatter_class = formatter)
  252. parser.add_argument("device", metavar="DEVICE", nargs="+",
  253. help="Serial device. Specify DEVICE@BAUD for "
  254. "per-device baudrates.")
  255. group = parser.add_mutually_exclusive_group(required = False)
  256. group.add_argument("--quiet", "-q", action="store_true",
  257. default=argparse.SUPPRESS,
  258. help="Don't print header "
  259. "(default, if stdout is not a tty)")
  260. group.add_argument("--no-quiet", "-Q", action="store_true",
  261. default=argparse.SUPPRESS,
  262. help="Print header at startup "
  263. "(default, if stdout is a tty)")
  264. parser.add_argument("--baudrate", "-b", metavar="BAUD", type=int,
  265. help="Default baudrate for all devices", default=115200)
  266. parser.add_argument("--crlf", "-c", action="store_true",
  267. help="Add CR before incoming LF")
  268. parser.add_argument("--all", "-a", action="store_true",
  269. help="Send keystrokes to all devices, not just "
  270. "the first one")
  271. parser.add_argument("--mono", "-m", action="store_true",
  272. help="Don't use colors in output")
  273. parser.add_argument("--flow", "-f", action="store_true",
  274. help="Enable RTS/CTS flow control")
  275. group = parser.add_mutually_exclusive_group(required = False)
  276. group.add_argument("--raw", "-r", action="store_true",
  277. default=argparse.SUPPRESS,
  278. help="Output characters directly "
  279. "(default, if stdout is not a tty)")
  280. group.add_argument("--no-raw", "-R", action="store_true",
  281. default=argparse.SUPPRESS,
  282. help="Quote unprintable characters "
  283. "(default, if stdout is a tty)")
  284. args = parser.parse_args()
  285. piped = not sys.stdout.isatty()
  286. raw = "raw" in args or (piped and "no_raw" not in args)
  287. quiet = "quiet" in args or (piped and "no_quiet" not in args)
  288. devs = []
  289. nodes = []
  290. bauds = []
  291. for (n, device) in enumerate(args.device):
  292. m = re.search(r"^(.*)@([1-9][0-9]*)$", device)
  293. if m is not None:
  294. node = m.group(1)
  295. baud = m.group(2)
  296. else:
  297. node = device
  298. baud = args.baudrate
  299. if node in nodes:
  300. sys.stderr.write("error: %s specified more than once\n" % node)
  301. raise SystemExit(1)
  302. try:
  303. dev = serial.Serial(node, baud, rtscts = args.flow)
  304. except serial.serialutil.SerialException:
  305. sys.stderr.write("error opening %s\n" % node)
  306. raise SystemExit(1)
  307. nodes.append(node)
  308. bauds.append(baud)
  309. devs.append(dev)
  310. term = Jimterm(devs,
  311. transmit_all = args.all,
  312. add_cr = args.crlf,
  313. raw = raw,
  314. color = (os.name == "posix" and not args.mono))
  315. if not quiet:
  316. term.print_header(nodes, bauds, sys.stderr, piped)
  317. term.run()