blob: 61a3307fa1270e6e8ca111c79dffe2806106d83b [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()
335 raw_input("Press any key to unpublish the service ")
336 service.unpublish()
337
338
339if __name__ == '__main__':
340 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
348 ]
Chris Liechtif5656932015-09-22 23:13:44 +0200349
350 parser = argparse.ArgumentParser(usage="""\
351%(prog)s [options]
352
353Announce the existence of devices using zeroconf and provide
354a TCP/IP <-> serial port gateway (implements RFC 2217).
355
356If running as daemon, write to syslog. Otherwise write to stdout.
357""",
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100358 epilog="""\
Chris Liechtif5656932015-09-22 23:13:44 +0200359NOTE: no security measures are implemented. Anyone can remotely connect
360to this service over the network.
361
362Only one connection at once, per port, is supported. When the connection is
363terminated, it waits for the next connect.
364""")
365
366 group = parser.add_argument_group("serial port settings")
367
368 group.add_argument(
369 "--ports-regex",
370 help="specify a regex to search against the serial devices and their descriptions (default: %(default)s)",
371 default='/dev/ttyUSB[0-9]+',
372 metavar="REGEX")
373
374 group = parser.add_argument_group("network settings")
375
376 group.add_argument(
377 "--tcp-port",
378 dest="base_port",
379 help="specify lowest TCP port number (default: %(default)s)",
380 default=7000,
381 type=int,
382 metavar="PORT")
383
384 group = parser.add_argument_group("daemon")
385
386 group.add_argument(
387 "-d", "--daemon",
388 dest="daemonize",
389 action="store_true",
390 help="start as daemon",
391 default=False)
392
393 group.add_argument(
394 "--pidfile",
395 help="specify a name for the PID file",
396 default=None,
397 metavar="FILE")
398
399 group = parser.add_argument_group("diagnostics")
400
401 group.add_argument(
402 "-o", "--logfile",
403 help="write messages file instead of stdout",
404 default=None,
405 metavar="FILE")
406
407 group.add_argument(
408 "-q", "--quiet",
409 dest="verbosity",
410 action="store_const",
411 const=0,
412 help="suppress most diagnostic messages",
413 default=1)
414
415 group.add_argument(
416 "-v", "--verbose",
417 dest="verbosity",
418 action="count",
419 help="increase diagnostic messages")
420
Chris Liechtif5656932015-09-22 23:13:44 +0200421 args = parser.parse_args()
422
423 # set up logging
424 logging.basicConfig(level=VERBOSTIY[min(args.verbosity, len(VERBOSTIY) - 1)])
425 log = logging.getLogger('port_publisher')
426
427 # redirect output if specified
428 if args.logfile is not None:
429 class WriteFlushed:
430 def __init__(self, fileobj):
431 self.fileobj = fileobj
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100432
Chris Liechtif5656932015-09-22 23:13:44 +0200433 def write(self, s):
434 self.fileobj.write(s)
435 self.fileobj.flush()
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100436
Chris Liechtif5656932015-09-22 23:13:44 +0200437 def close(self):
438 self.fileobj.close()
439 sys.stdout = sys.stderr = WriteFlushed(open(args.logfile, 'a'))
440 # atexit.register(lambda: sys.stdout.close())
441
442 if args.daemonize:
443 # if running as daemon is requested, do the fork magic
444 # args.quiet = True
445 # do the UNIX double-fork magic, see Stevens' "Advanced
446 # Programming in the UNIX Environment" for details (ISBN 0201563177)
447 try:
448 pid = os.fork()
449 if pid > 0:
450 # exit first parent
451 sys.exit(0)
452 except OSError as e:
453 log.critical("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
454 sys.exit(1)
455
456 # decouple from parent environment
457 os.chdir("/") # don't prevent unmounting....
458 os.setsid()
459 os.umask(0)
460
461 # do second fork
462 try:
463 pid = os.fork()
464 if pid > 0:
465 # exit from second parent, save eventual PID before
466 if args.pidfile is not None:
467 open(args.pidfile, 'w').write("%d" % pid)
468 sys.exit(0)
469 except OSError as e:
470 log.critical("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
471 sys.exit(1)
472
473 if args.logfile is None:
474 import syslog
475 syslog.openlog("serial port publisher")
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100476
Chris Liechtif5656932015-09-22 23:13:44 +0200477 # redirect output to syslog
478 class WriteToSysLog:
479 def __init__(self):
480 self.buffer = ''
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100481
Chris Liechtif5656932015-09-22 23:13:44 +0200482 def write(self, s):
483 self.buffer += s
484 if '\n' in self.buffer:
485 output, self.buffer = self.buffer.split('\n', 1)
486 syslog.syslog(output)
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100487
Chris Liechtif5656932015-09-22 23:13:44 +0200488 def flush(self):
489 syslog.syslog(self.buffer)
490 self.buffer = ''
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100491
Chris Liechtif5656932015-09-22 23:13:44 +0200492 def close(self):
493 self.flush()
494 sys.stdout = sys.stderr = WriteToSysLog()
495
496 # ensure the that the daemon runs a normal user, if run as root
Chris Liechti3d3e71e2016-01-24 23:55:05 +0100497 # if os.getuid() == 0:
Chris Liechtif5656932015-09-22 23:13:44 +0200498 # name, passwd, uid, gid, desc, home, shell = pwd.getpwnam('someuser')
499 # os.setgid(gid) # set group first
500 # os.setuid(uid) # set user
501
502 # keep the published stuff in a dictionary
503 published = {}
504 # get a nice hostname
505 hostname = socket.gethostname()
506
507 def unpublish(forwarder):
508 """when forwarders die, we need to unregister them"""
509 try:
510 del published[forwarder.device]
511 except KeyError:
512 pass
513 else:
514 log.info("unpublish: %s" % (forwarder))
515
516 alive = True
517 next_check = 0
518 # main loop
519 while alive:
520 try:
521 # if it is time, check for serial port devices
522 now = time.time()
523 if now > next_check:
524 next_check = now + 5
525 connected = [d for d, p, i in serial.tools.list_ports.grep(args.ports_regex)]
526 # Handle devices that are published, but no longer connected
527 for device in set(published).difference(connected):
528 log.info("unpublish: %s" % (published[device]))
529 unpublish(published[device])
530 # Handle devices that are connected but not yet published
Chris Liechti61aa26b2016-01-01 22:55:52 +0100531 for device in sorted(set(connected).difference(published)):
532 # Find the first available port, starting from specified number
Chris Liechtif5656932015-09-22 23:13:44 +0200533 port = args.base_port
534 ports_in_use = [f.network_port for f in published.values()]
535 while port in ports_in_use:
536 port += 1
537 published[device] = Forwarder(
538 device,
539 "%s on %s" % (device, hostname),
540 port,
541 on_close=unpublish,
Chris Liechti61aa26b2016-01-01 22:55:52 +0100542 log=log)
Chris Liechtif5656932015-09-22 23:13:44 +0200543 log.warning("publish: %s" % (published[device]))
544 published[device].open()
545
546 # select_start = time.time()
547 read_map = {}
548 write_map = {}
549 error_map = {}
550 for publisher in published.values():
551 publisher.update_select_maps(read_map, write_map, error_map)
552 readers, writers, errors = select.select(
553 read_map.keys(),
554 write_map.keys(),
555 error_map.keys(),
Chris Liechti61aa26b2016-01-01 22:55:52 +0100556 5)
Chris Liechtif5656932015-09-22 23:13:44 +0200557 # select_end = time.time()
558 # print "select used %.3f s" % (select_end - select_start)
559 for reader in readers:
560 read_map[reader]()
561 for writer in writers:
562 write_map[writer]()
563 for error in errors:
564 error_map[error]()
565 # print "operation used %.3f s" % (time.time() - select_end)
566 except KeyboardInterrupt:
567 alive = False
568 sys.stdout.write('\n')
569 except SystemExit:
570 raise
571 except:
572 #~ raise
573 traceback.print_exc()