blob: cf449456df556c5fbbfa00352a5c64b02bfae5b3 [file] [log] [blame]
Chris Liechtif5656932015-09-22 23:13:44 +02001#! /usr/bin/env python
2#
3# (C) 2001-2015 Chris Liechti <cliechti@gmx.net>
4#
5# SPDX-License-Identifier: BSD-3-Clause
6"""\
7Multi-port serial<->TCP/IP forwarder.
8- RFC 2217
9- check existence of serial port periodically
10- start/stop forwarders
11- each forwarder creates a server socket and opens the serial port
12- serial ports are opened only once. network connect/disconnect
13 does not influence serial port
14- only one client per connection
15"""
16import os
17import select
18import socket
19import sys
20import time
21import traceback
22
23import serial
24import serial.rfc2217
25import serial.tools.list_ports
26
27import dbus
28
29# Try to import the avahi service definitions properly. If the avahi module is
30# not available, fall back to a hard-coded solution that hopefully still works.
31try:
32 import avahi
33except ImportError:
34 class avahi:
35 DBUS_NAME = "org.freedesktop.Avahi"
36 DBUS_PATH_SERVER = "/"
37 DBUS_INTERFACE_SERVER = "org.freedesktop.Avahi.Server"
38 DBUS_INTERFACE_ENTRY_GROUP = DBUS_NAME + ".EntryGroup"
39 IF_UNSPEC = -1
40 PROTO_UNSPEC, PROTO_INET, PROTO_INET6 = -1, 0, 1
41
42
43class ZeroconfService:
44 """\
45 A simple class to publish a network service with zeroconf using avahi.
46 """
47
48 def __init__(self, name, port, stype="_http._tcp",
49 domain="", host="", text=""):
50 self.name = name
51 self.stype = stype
52 self.domain = domain
53 self.host = host
54 self.port = port
55 self.text = text
56 self.group = None
57
58 def publish(self):
59 bus = dbus.SystemBus()
60 server = dbus.Interface(
61 bus.get_object(
62 avahi.DBUS_NAME,
63 avahi.DBUS_PATH_SERVER
64 ),
65 avahi.DBUS_INTERFACE_SERVER
66 )
67
68 g = dbus.Interface(
69 bus.get_object(
70 avahi.DBUS_NAME,
71 server.EntryGroupNew()
72 ),
73 avahi.DBUS_INTERFACE_ENTRY_GROUP
74 )
75
76 g.AddService(avahi.IF_UNSPEC, avahi.PROTO_UNSPEC, dbus.UInt32(0),
77 self.name, self.stype, self.domain, self.host,
78 dbus.UInt16(self.port), self.text)
79
80 g.Commit()
81 self.group = g
82
83 def unpublish(self):
84 if self.group is not None:
85 self.group.Reset()
86 self.group = None
87
88 def __str__(self):
89 return "%r @ %s:%s (%s)" % (self.name, self.host, self.port, self.stype)
90
91
92class Forwarder(ZeroconfService):
93 """\
94 Single port serial<->TCP/IP forarder that depends on an external select
95 loop.
96 - Buffers for serial -> network and network -> serial
97 - RFC 2217 state
98 - Zeroconf publish/unpublish on open/close.
99 """
100
101 def __init__(self, device, name, network_port, on_close=None, log=None):
102 ZeroconfService.__init__(self, name, network_port, stype='_serial_port._tcp')
103 self.alive = False
104 self.network_port = network_port
105 self.on_close = on_close
106 self.log = log
107 self.device = device
108 self.serial = serial.Serial()
109 self.serial.port = device
110 self.serial.baudrate = 115200
111 self.serial.timeout = 0
112 self.socket = None
113 self.server_socket = None
114 self.rfc2217 = None # instantiate later, when connecting
115
116 def __del__(self):
117 try:
118 if self.alive:
119 self.close()
120 except:
121 pass # XXX errors on shutdown
122
123 def open(self):
124 """open serial port, start network server and publish service"""
125 self.buffer_net2ser = bytearray()
126 self.buffer_ser2net = bytearray()
127
128 # open serial port
129 try:
130 self.serial.rts = False
131 self.serial.open()
132 except Exception as msg:
133 self.handle_serial_error(msg)
134
135 self.serial_settings_backup = self.serial.get_settings()
136
137 # start the socket server
138 # XXX add IPv6 support: use getaddrinfo for socket options, bind to multiple sockets?
139 # info_list = socket.getaddrinfo(None, port, 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
140 self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
141 self.server_socket.setsockopt(
142 socket.SOL_SOCKET,
143 socket.SO_REUSEADDR,
144 self.server_socket.getsockopt(
145 socket.SOL_SOCKET,
146 socket.SO_REUSEADDR
147 ) | 1
148 )
149 self.server_socket.setblocking(0)
150 try:
151 self.server_socket.bind(('', self.network_port))
152 self.server_socket.listen(1)
153 except socket.error as msg:
154 self.handle_server_error()
155 #~ raise
156 if self.log is not None:
157 self.log.info("%s: Waiting for connection on %s..." % (self.device, self.network_port))
158
159 # zeroconfig
160 self.publish()
161
162 # now we are ready
163 self.alive = True
164
165 def close(self):
166 """Close all resources and unpublish service"""
167 if self.log is not None:
168 self.log.info("%s: closing..." % (self.device, ))
169 self.alive = False
170 self.unpublish()
171 if self.server_socket:
172 self.server_socket.close()
173 if self.socket:
174 self.handle_disconnect()
175 self.serial.close()
176 if self.on_close is not None:
177 # ensure it is only called once
178 callback = self.on_close
179 self.on_close = None
180 callback(self)
181
182 def write(self, data):
183 """the write method is used by serial.rfc2217.PortManager. it has to
184 write to the network."""
185 self.buffer_ser2net += data
186
187 def update_select_maps(self, read_map, write_map, error_map):
188 """Update dictionaries for select call. insert fd->callback mapping"""
189 if self.alive:
190 # always handle serial port reads
191 read_map[self.serial] = self.handle_serial_read
192 error_map[self.serial] = self.handle_serial_error
193 # handle serial port writes if buffer is not empty
194 if self.buffer_net2ser:
195 write_map[self.serial] = self.handle_serial_write
196 # handle network
197 if self.socket is not None:
198 # handle socket if connected
199 # only read from network if the internal buffer is not
200 # already filled. the TCP flow control will hold back data
201 if len(self.buffer_net2ser) < 2048:
202 read_map[self.socket] = self.handle_socket_read
203 # only check for write readiness when there is data
204 if self.buffer_ser2net:
205 write_map[self.socket] = self.handle_socket_write
206 error_map[self.socket] = self.handle_socket_error
207 else:
208 # no connection, ensure clear buffer
209 self.buffer_ser2net = bytearray()
210 # check the server socket
211 read_map[self.server_socket] = self.handle_connect
212 error_map[self.server_socket] = self.handle_server_error
213
214 def handle_serial_read(self):
215 """Reading from serial port"""
216 try:
217 data = os.read(self.serial.fileno(), 1024)
218 if data:
219 # store data in buffer if there is a client connected
220 if self.socket is not None:
221 # escape outgoing data when needed (Telnet IAC (0xff) character)
222 if self.rfc2217:
223 data = serial.to_bytes(self.rfc2217.escape(data))
224 self.buffer_ser2net += data
225 else:
226 self.handle_serial_error()
227 except Exception as msg:
228 self.handle_serial_error(msg)
229
230 def handle_serial_write(self):
231 """Writing to serial port"""
232 try:
233 # write a chunk
234 n = os.write(self.serial.fileno(), bytes(self.buffer_net2ser))
235 # and see how large that chunk was, remove that from buffer
236 self.buffer_net2ser = self.buffer_net2ser[n:]
237 except Exception as msg:
238 self.handle_serial_error(msg)
239
240 def handle_serial_error(self, error=None):
241 """Serial port error"""
242 # terminate connection
243 self.close()
244
245 def handle_socket_read(self):
246 """Read from socket"""
247 try:
248 # read a chunk from the serial port
249 data = self.socket.recv(1024)
250 if data:
251 # Process RFC 2217 stuff when enabled
252 if self.rfc2217:
253 data = serial.to_bytes(self.rfc2217.filter(data))
254 # add data to buffer
255 self.buffer_net2ser += data
256 else:
257 # empty read indicates disconnection
258 self.handle_disconnect()
259 except socket.error:
260 self.handle_socket_error()
261
262 def handle_socket_write(self):
263 """Write to socket"""
264 try:
265 # write a chunk
266 count = self.socket.send(bytes(self.buffer_ser2net))
267 # and remove the sent data from the buffer
268 self.buffer_ser2net = self.buffer_ser2net[count:]
269 except socket.error:
270 self.handle_socket_error()
271
272 def handle_socket_error(self):
273 """Socket connection fails"""
274 self.handle_disconnect()
275
276 def handle_connect(self):
277 """Server socket gets a connection"""
278 # accept a connection in any case, close connection
279 # below if already busy
280 connection, addr = self.server_socket.accept()
281 if self.socket is None:
282 self.socket = connection
283 # More quickly detect bad clients who quit without closing the
284 # connection: After 1 second of idle, start sending TCP keep-alive
285 # packets every 1 second. If 3 consecutive keep-alive packets
286 # fail, assume the client is gone and close the connection.
287 self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
288 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1)
289 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 1)
290 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
291 self.socket.setblocking(0)
292 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
293 if self.log is not None:
294 self.log.warning('%s: Connected by %s:%s' % (self.device, addr[0], addr[1]))
295 self.serial.rts = True
296 self.serial.dtr = True
297 if self.log is not None:
298 self.rfc2217 = serial.rfc2217.PortManager(self.serial, self, logger=log.getChild(self.device))
299 else:
300 self.rfc2217 = serial.rfc2217.PortManager(self.serial, self)
301 else:
302 # reject connection if there is already one
303 connection.close()
304 if self.log is not None:
305 self.log.warning('%s: Rejecting connect from %s:%s' % (self.device, addr[0], addr[1]))
306
307 def handle_server_error(self):
308 """Socket server fails"""
309 self.close()
310
311 def handle_disconnect(self):
312 """Socket gets disconnected"""
313 # signal disconnected terminal with control lines
314 try:
315 self.serial.rts = False
316 self.serial.dtr = False
317 finally:
318 # restore original port configuration in case it was changed
319 self.serial.apply_settings(self.serial_settings_backup)
320 # stop RFC 2217 state machine
321 self.rfc2217 = None
322 # clear send buffer
323 self.buffer_ser2net = bytearray()
324 # close network connection
325 if self.socket is not None:
326 self.socket.close()
327 self.socket = None
328 if self.log is not None:
329 self.log.warning('%s: Disconnected' % self.device)
330
331
332def test():
333 service = ZeroconfService(name="TestService", port=3000)
334 service.publish()
Chris Liechti0269b2c2016-02-14 23:45:23 +0100335 input("Press the ENTER key to unpublish the service ")
Chris Liechtif5656932015-09-22 23:13:44 +0200336 service.unpublish()
337
338
Chris Liechti0269b2c2016-02-14 23:45:23 +0100339if __name__ == '__main__': # noqa
Chris Liechtif5656932015-09-22 23:13:44 +0200340 import logging
341 import argparse
342
343 VERBOSTIY = [
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100344 logging.ERROR, # 0
345 logging.WARNING, # 1 (default)
346 logging.INFO, # 2
347 logging.DEBUG, # 3
Chris Liechti0269b2c2016-02-14 23:45:23 +0100348 ]
Chris Liechtif5656932015-09-22 23:13:44 +0200349
Chris Liechti0269b2c2016-02-14 23:45:23 +0100350 parser = argparse.ArgumentParser(
351 usage="""\
Chris Liechtif5656932015-09-22 23:13:44 +0200352%(prog)s [options]
353
354Announce the existence of devices using zeroconf and provide
355a TCP/IP <-> serial port gateway (implements RFC 2217).
356
357If running as daemon, write to syslog. Otherwise write to stdout.
358""",
Chris Liechti0269b2c2016-02-14 23:45:23 +0100359 epilog="""\
Chris Liechtif5656932015-09-22 23:13:44 +0200360NOTE: no security measures are implemented. Anyone can remotely connect
361to this service over the network.
362
363Only one connection at once, per port, is supported. When the connection is
364terminated, it waits for the next connect.
365""")
366
367 group = parser.add_argument_group("serial port settings")
368
369 group.add_argument(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100370 "--ports-regex",
371 help="specify a regex to search against the serial devices and their descriptions (default: %(default)s)",
372 default='/dev/ttyUSB[0-9]+',
373 metavar="REGEX")
Chris Liechtif5656932015-09-22 23:13:44 +0200374
375 group = parser.add_argument_group("network settings")
376
377 group.add_argument(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100378 "--tcp-port",
379 dest="base_port",
380 help="specify lowest TCP port number (default: %(default)s)",
381 default=7000,
382 type=int,
383 metavar="PORT")
Chris Liechtif5656932015-09-22 23:13:44 +0200384
385 group = parser.add_argument_group("daemon")
386
387 group.add_argument(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100388 "-d", "--daemon",
389 dest="daemonize",
390 action="store_true",
391 help="start as daemon",
392 default=False)
Chris Liechtif5656932015-09-22 23:13:44 +0200393
394 group.add_argument(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100395 "--pidfile",
396 help="specify a name for the PID file",
397 default=None,
398 metavar="FILE")
Chris Liechtif5656932015-09-22 23:13:44 +0200399
400 group = parser.add_argument_group("diagnostics")
401
402 group.add_argument(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100403 "-o", "--logfile",
404 help="write messages file instead of stdout",
405 default=None,
406 metavar="FILE")
Chris Liechtif5656932015-09-22 23:13:44 +0200407
408 group.add_argument(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100409 "-q", "--quiet",
410 dest="verbosity",
411 action="store_const",
412 const=0,
413 help="suppress most diagnostic messages",
414 default=1)
Chris Liechtif5656932015-09-22 23:13:44 +0200415
416 group.add_argument(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100417 "-v", "--verbose",
418 dest="verbosity",
419 action="count",
420 help="increase diagnostic messages")
Chris Liechtif5656932015-09-22 23:13:44 +0200421
Chris Liechtif5656932015-09-22 23:13:44 +0200422 args = parser.parse_args()
423
424 # set up logging
425 logging.basicConfig(level=VERBOSTIY[min(args.verbosity, len(VERBOSTIY) - 1)])
426 log = logging.getLogger('port_publisher')
427
428 # redirect output if specified
429 if args.logfile is not None:
430 class WriteFlushed:
431 def __init__(self, fileobj):
432 self.fileobj = fileobj
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100433
Chris Liechtif5656932015-09-22 23:13:44 +0200434 def write(self, s):
435 self.fileobj.write(s)
436 self.fileobj.flush()
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100437
Chris Liechtif5656932015-09-22 23:13:44 +0200438 def close(self):
439 self.fileobj.close()
440 sys.stdout = sys.stderr = WriteFlushed(open(args.logfile, 'a'))
441 # atexit.register(lambda: sys.stdout.close())
442
443 if args.daemonize:
444 # if running as daemon is requested, do the fork magic
445 # args.quiet = True
446 # do the UNIX double-fork magic, see Stevens' "Advanced
447 # Programming in the UNIX Environment" for details (ISBN 0201563177)
448 try:
449 pid = os.fork()
450 if pid > 0:
451 # exit first parent
452 sys.exit(0)
453 except OSError as e:
454 log.critical("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
455 sys.exit(1)
456
457 # decouple from parent environment
458 os.chdir("/") # don't prevent unmounting....
459 os.setsid()
460 os.umask(0)
461
462 # do second fork
463 try:
464 pid = os.fork()
465 if pid > 0:
466 # exit from second parent, save eventual PID before
467 if args.pidfile is not None:
468 open(args.pidfile, 'w').write("%d" % pid)
469 sys.exit(0)
470 except OSError as e:
471 log.critical("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
472 sys.exit(1)
473
474 if args.logfile is None:
475 import syslog
476 syslog.openlog("serial port publisher")
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100477
Chris Liechtif5656932015-09-22 23:13:44 +0200478 # redirect output to syslog
479 class WriteToSysLog:
480 def __init__(self):
481 self.buffer = ''
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100482
Chris Liechtif5656932015-09-22 23:13:44 +0200483 def write(self, s):
484 self.buffer += s
485 if '\n' in self.buffer:
486 output, self.buffer = self.buffer.split('\n', 1)
487 syslog.syslog(output)
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100488
Chris Liechtif5656932015-09-22 23:13:44 +0200489 def flush(self):
490 syslog.syslog(self.buffer)
491 self.buffer = ''
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100492
Chris Liechtif5656932015-09-22 23:13:44 +0200493 def close(self):
494 self.flush()
495 sys.stdout = sys.stderr = WriteToSysLog()
496
497 # ensure the that the daemon runs a normal user, if run as root
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100498 # if os.getuid() == 0:
Chris Liechtif5656932015-09-22 23:13:44 +0200499 # name, passwd, uid, gid, desc, home, shell = pwd.getpwnam('someuser')
500 # os.setgid(gid) # set group first
501 # os.setuid(uid) # set user
502
503 # keep the published stuff in a dictionary
504 published = {}
505 # get a nice hostname
506 hostname = socket.gethostname()
507
508 def unpublish(forwarder):
509 """when forwarders die, we need to unregister them"""
510 try:
511 del published[forwarder.device]
512 except KeyError:
513 pass
514 else:
515 log.info("unpublish: %s" % (forwarder))
516
517 alive = True
518 next_check = 0
519 # main loop
520 while alive:
521 try:
522 # if it is time, check for serial port devices
523 now = time.time()
524 if now > next_check:
525 next_check = now + 5
526 connected = [d for d, p, i in serial.tools.list_ports.grep(args.ports_regex)]
527 # Handle devices that are published, but no longer connected
528 for device in set(published).difference(connected):
529 log.info("unpublish: %s" % (published[device]))
530 unpublish(published[device])
531 # Handle devices that are connected but not yet published
Chris Liechti61aa26b2016-01-01 22:55:52 +0100532 for device in sorted(set(connected).difference(published)):
533 # Find the first available port, starting from specified number
Chris Liechtif5656932015-09-22 23:13:44 +0200534 port = args.base_port
535 ports_in_use = [f.network_port for f in published.values()]
536 while port in ports_in_use:
537 port += 1
538 published[device] = Forwarder(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100539 device,
540 "%s on %s" % (device, hostname),
541 port,
542 on_close=unpublish,
543 log=log)
Chris Liechtif5656932015-09-22 23:13:44 +0200544 log.warning("publish: %s" % (published[device]))
545 published[device].open()
546
547 # select_start = time.time()
548 read_map = {}
549 write_map = {}
550 error_map = {}
551 for publisher in published.values():
552 publisher.update_select_maps(read_map, write_map, error_map)
553 readers, writers, errors = select.select(
Chris Liechti0269b2c2016-02-14 23:45:23 +0100554 read_map.keys(),
555 write_map.keys(),
556 error_map.keys(),
557 5)
Chris Liechtif5656932015-09-22 23:13:44 +0200558 # select_end = time.time()
559 # print "select used %.3f s" % (select_end - select_start)
560 for reader in readers:
561 read_map[reader]()
562 for writer in writers:
563 write_map[writer]()
564 for error in errors:
565 error_map[error]()
566 # print "operation used %.3f s" % (time.time() - select_end)
567 except KeyboardInterrupt:
568 alive = False
569 sys.stdout.write('\n')
570 except SystemExit:
571 raise
572 except:
573 #~ raise
574 traceback.print_exc()