blob: 22b97a7c2cd9914ecd616124eace1d9879eb4b88 [file] [log] [blame]
cliechtif1c882c2009-07-23 02:30:06 +00001#! /usr/bin/env python
2"""\
3Multi-port serial<->TCP/IP forwarder.
cliechti32c10332009-08-05 13:23:43 +00004- RFC 2217
cliechtif1c882c2009-07-23 02:30:06 +00005- check existence of serial port periodically
6- start/stop forwarders
7- each forwarder creates a server socket and opens the serial port
8- serial ports are opened only once. network connect/disconnect
9 does not influence serial port
10- only one client per connection
11"""
Chris Liechti4caf6a52015-08-04 01:07:45 +020012import os
cliechtif1c882c2009-07-23 02:30:06 +000013import select
Chris Liechti4caf6a52015-08-04 01:07:45 +020014import socket
15import sys
16import time
17import traceback
cliechti32c10332009-08-05 13:23:43 +000018
cliechtif1c882c2009-07-23 02:30:06 +000019import serial
cliechti32c10332009-08-05 13:23:43 +000020import serial.rfc2217
Chris Liechti6e683ed2015-08-04 16:59:04 +020021import serial.tools.list_ports
cliechti32c10332009-08-05 13:23:43 +000022
cliechtif1c882c2009-07-23 02:30:06 +000023import dbus
24
Chris Liechti6e683ed2015-08-04 16:59:04 +020025# Try to import the avahi service definitions properly. If the avahi module is
26# not available, fall back to a hard-coded solution that hopefully still works.
27try:
28 import avahi
29except ImportError:
30 class avahi:
31 DBUS_NAME = "org.freedesktop.Avahi"
32 DBUS_PATH_SERVER = "/"
33 DBUS_INTERFACE_SERVER = "org.freedesktop.Avahi.Server"
34 DBUS_INTERFACE_ENTRY_GROUP = DBUS_NAME + ".EntryGroup"
35 IF_UNSPEC = -1
36 PROTO_UNSPEC, PROTO_INET, PROTO_INET6 = -1, 0, 1
37
38
39
cliechtif1c882c2009-07-23 02:30:06 +000040class ZeroconfService:
41 """\
42 A simple class to publish a network service with zeroconf using avahi.
43 """
44
45 def __init__(self, name, port, stype="_http._tcp",
46 domain="", host="", text=""):
47 self.name = name
48 self.stype = stype
49 self.domain = domain
50 self.host = host
51 self.port = port
52 self.text = text
53 self.group = None
54
55 def publish(self):
56 bus = dbus.SystemBus()
57 server = dbus.Interface(
58 bus.get_object(
59 avahi.DBUS_NAME,
60 avahi.DBUS_PATH_SERVER
61 ),
62 avahi.DBUS_INTERFACE_SERVER
63 )
64
65 g = dbus.Interface(
66 bus.get_object(
67 avahi.DBUS_NAME,
68 server.EntryGroupNew()
69 ),
70 avahi.DBUS_INTERFACE_ENTRY_GROUP
71 )
72
73 g.AddService(avahi.IF_UNSPEC, avahi.PROTO_UNSPEC, dbus.UInt32(0),
74 self.name, self.stype, self.domain, self.host,
75 dbus.UInt16(self.port), self.text)
76
77 g.Commit()
78 self.group = g
79
80 def unpublish(self):
81 if self.group is not None:
82 self.group.Reset()
83 self.group = None
84
85 def __str__(self):
86 return "%r @ %s:%s (%s)" % (self.name, self.host, self.port, self.stype)
87
88
89
90class Forwarder(ZeroconfService):
91 """\
92 Single port serial<->TCP/IP forarder that depends on an external select
cliechti32c10332009-08-05 13:23:43 +000093 loop.
94 - Buffers for serial -> network and network -> serial
95 - RFC 2217 state
96 - Zeroconf publish/unpublish on open/close.
cliechtif1c882c2009-07-23 02:30:06 +000097 """
98
99 def __init__(self, device, name, network_port, on_close=None):
100 ZeroconfService.__init__(self, name, network_port, stype='_serial_port._tcp')
101 self.alive = False
102 self.network_port = network_port
103 self.on_close = on_close
104 self.device = device
105 self.serial = serial.Serial()
106 self.serial.port = device
107 self.serial.baudrate = 115200
108 self.serial.timeout = 0
109 self.socket = None
110 self.server_socket = None
cliechti32c10332009-08-05 13:23:43 +0000111 self.rfc2217 = None # instantiate later, when connecting
cliechtif1c882c2009-07-23 02:30:06 +0000112
113 def __del__(self):
114 try:
115 if self.alive: self.close()
116 except:
117 pass # XXX errors on shutdown
118
119 def open(self):
120 """open serial port, start network server and publish service"""
121 self.buffer_net2ser = ''
122 self.buffer_ser2net = ''
123
124 # open serial port
125 try:
126 self.serial.open()
127 self.serial.setRTS(False)
Chris Liechti4caf6a52015-08-04 01:07:45 +0200128 except Exception as msg:
cliechtif1c882c2009-07-23 02:30:06 +0000129 self.handle_serial_error(msg)
130
cliechtid9a06ce2009-08-10 01:30:53 +0000131 self.serial_settings_backup = self.serial.getSettingsDict()
132
cliechtif1c882c2009-07-23 02:30:06 +0000133 # start the socket server
134 self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
135 self.server_socket.setsockopt(
136 socket.SOL_SOCKET,
137 socket.SO_REUSEADDR,
138 self.server_socket.getsockopt(
139 socket.SOL_SOCKET,
140 socket.SO_REUSEADDR
141 ) | 1
142 )
143 self.server_socket.setblocking(0)
144 try:
145 self.server_socket.bind( ('', self.network_port) )
146 self.server_socket.listen(1)
Chris Liechti4caf6a52015-08-04 01:07:45 +0200147 except socket.error as msg:
cliechtif1c882c2009-07-23 02:30:06 +0000148 self.handle_server_error()
149 #~ raise
150 if not options.quiet:
Chris Liechti6e683ed2015-08-04 16:59:04 +0200151 print("%s: Waiting for connection on %s..." % (self.device, self.network_port))
cliechtif1c882c2009-07-23 02:30:06 +0000152
153 # zeroconfig
154 self.publish()
155
156 # now we are ready
157 self.alive = True
158
159 def close(self):
160 """Close all resources and unpublish service"""
161 if not options.quiet:
Chris Liechti4caf6a52015-08-04 01:07:45 +0200162 print("%s: closing..." % (self.device, ))
cliechtif1c882c2009-07-23 02:30:06 +0000163 self.alive = False
164 self.unpublish()
165 if self.server_socket: self.server_socket.close()
166 if self.socket:
167 self.handle_disconnect()
168 self.serial.close()
169 if self.on_close is not None:
170 # ensure it is only called once
171 callback = self.on_close
172 self.on_close = None
173 callback(self)
174
cliechti32c10332009-08-05 13:23:43 +0000175 def write(self, data):
176 """the write method is used by serial.rfc2217.PortManager. it has to
177 write to the network."""
178 self.buffer_ser2net += data
179
cliechtif1c882c2009-07-23 02:30:06 +0000180 def update_select_maps(self, read_map, write_map, error_map):
181 """Update dictionaries for select call. insert fd->callback mapping"""
182 if self.alive:
183 # always handle serial port reads
184 read_map[self.serial] = self.handle_serial_read
185 error_map[self.serial] = self.handle_serial_error
186 # handle serial port writes if buffer is not empty
187 if self.buffer_net2ser:
188 write_map[self.serial] = self.handle_serial_write
189 # handle network
190 if self.socket is not None:
191 # handle socket if connected
192 # only read from network if the internal buffer is not
193 # already filled. the TCP flow control will hold back data
194 if len(self.buffer_net2ser) < 2048:
195 read_map[self.socket] = self.handle_socket_read
196 # only check for write readiness when there is data
197 if self.buffer_ser2net:
198 write_map[self.socket] = self.handle_socket_write
199 error_map[self.socket] = self.handle_socket_error
200 else:
201 # no connection, ensure clear buffer
202 self.buffer_ser2net = ''
203 # check the server socket
204 read_map[self.server_socket] = self.handle_connect
205 error_map[self.server_socket] = self.handle_server_error
206
207
208 def handle_serial_read(self):
209 """Reading from serial port"""
210 try:
211 data = os.read(self.serial.fileno(), 1024)
212 if data:
213 # store data in buffer if there is a client connected
214 if self.socket is not None:
cliechti32c10332009-08-05 13:23:43 +0000215 # escape outgoing data when needed (Telnet IAC (0xff) character)
216 if self.rfc2217:
217 data = serial.to_bytes(self.rfc2217.escape(data))
cliechtif1c882c2009-07-23 02:30:06 +0000218 self.buffer_ser2net += data
219 else:
220 self.handle_serial_error()
Chris Liechti4caf6a52015-08-04 01:07:45 +0200221 except Exception as msg:
cliechtif1c882c2009-07-23 02:30:06 +0000222 self.handle_serial_error(msg)
223
224 def handle_serial_write(self):
225 """Writing to serial port"""
226 try:
227 # write a chunk
228 n = os.write(self.serial.fileno(), self.buffer_net2ser)
229 # and see how large that chunk was, remove that from buffer
230 self.buffer_net2ser = self.buffer_net2ser[n:]
Chris Liechti4caf6a52015-08-04 01:07:45 +0200231 except Exception as msg:
cliechtif1c882c2009-07-23 02:30:06 +0000232 self.handle_serial_error(msg)
233
234 def handle_serial_error(self, error=None):
235 """Serial port error"""
236 # terminate connection
237 self.close()
238
239 def handle_socket_read(self):
240 """Read from socket"""
241 try:
242 # read a chunk from the serial port
243 data = self.socket.recv(1024)
244 if data:
cliechti32c10332009-08-05 13:23:43 +0000245 # Process RFC 2217 stuff when enabled
246 if self.rfc2217:
247 data = serial.to_bytes(self.rfc2217.filter(data))
cliechtif1c882c2009-07-23 02:30:06 +0000248 # add data to buffer
249 self.buffer_net2ser += data
250 else:
251 # empty read indicates disconnection
252 self.handle_disconnect()
253 except socket.error:
254 self.handle_socket_error()
255
256 def handle_socket_write(self):
257 """Write to socket"""
258 try:
259 # write a chunk
260 count = self.socket.send(self.buffer_ser2net)
261 # and remove the sent data from the buffer
262 self.buffer_ser2net = self.buffer_ser2net[count:]
263 except socket.error:
264 self.handle_socket_error()
265
266 def handle_socket_error(self):
267 """Socket connection fails"""
268 self.handle_disconnect()
269
270 def handle_connect(self):
271 """Server socket gets a connection"""
272 # accept a connection in any case, close connection
273 # below if already busy
274 connection, addr = self.server_socket.accept()
275 if self.socket is None:
276 self.socket = connection
Chris Liechti6e683ed2015-08-04 16:59:04 +0200277 # More quickly detect bad clients who quit without closing the
278 # connection: After 1 second of idle, start sending TCP keep-alive
279 # packets every 1 second. If 3 consecutive keep-alive packets
280 # fail, assume the client is gone and close the connection.
281 self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
282 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1)
283 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 1)
284 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
cliechtif1c882c2009-07-23 02:30:06 +0000285 self.socket.setblocking(0)
cliechtia35cad42009-08-10 20:57:48 +0000286 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
cliechtif1c882c2009-07-23 02:30:06 +0000287 if not options.quiet:
Chris Liechti4caf6a52015-08-04 01:07:45 +0200288 print('%s: Connected by %s:%s' % (self.device, addr[0], addr[1]))
cliechtid9a06ce2009-08-10 01:30:53 +0000289 self.serial.setRTS(True)
290 self.serial.setDTR(True)
cliechti568dd3c2009-08-17 21:39:47 +0000291 self.rfc2217 = serial.rfc2217.PortManager(self.serial, self)
cliechtif1c882c2009-07-23 02:30:06 +0000292 else:
293 # reject connection if there is already one
294 connection.close()
295 if not options.quiet:
Chris Liechti4caf6a52015-08-04 01:07:45 +0200296 print('%s: Rejecting connect from %s:%s' % (self.device, addr[0], addr[1]))
cliechtif1c882c2009-07-23 02:30:06 +0000297
298 def handle_server_error(self):
cliechti7aed8332009-08-05 14:19:31 +0000299 """Socket server fails"""
cliechtif1c882c2009-07-23 02:30:06 +0000300 self.close()
301
302 def handle_disconnect(self):
303 """Socket gets disconnected"""
cliechtid9a06ce2009-08-10 01:30:53 +0000304 # signal disconnected terminal with control lines
cliechtie67c1f42009-09-10 15:07:44 +0000305 try:
306 self.serial.setRTS(False)
307 self.serial.setDTR(False)
308 finally:
309 # restore original port configuration in case it was changed
310 self.serial.applySettingsDict(self.serial_settings_backup)
311 # stop RFC 2217 state machine
312 self.rfc2217 = None
313 # clear send buffer
314 self.buffer_ser2net = ''
315 # close network connection
316 if self.socket is not None:
317 self.socket.close()
318 self.socket = None
319 if not options.quiet:
Chris Liechti4caf6a52015-08-04 01:07:45 +0200320 print('%s: Disconnected' % self.device)
cliechtif1c882c2009-07-23 02:30:06 +0000321
322
323def test():
324 service = ZeroconfService(name="TestService", port=3000)
325 service.publish()
326 raw_input("Press any key to unpublish the service ")
327 service.unpublish()
328
329
330if __name__ == '__main__':
331 import optparse
332
333 parser = optparse.OptionParser(usage="""\
334%prog [options]
335
336Announce the existence of devices using zeroconf and provide
cliechti32c10332009-08-05 13:23:43 +0000337a TCP/IP <-> serial port gateway (implements RFC 2217).
cliechtif1c882c2009-07-23 02:30:06 +0000338
339Note that the TCP/IP server is not protected. Everyone can connect
340to it!
341
342If running as daemon, write to syslog. Otherwise write to stdout.
343""")
344
Chris Liechti7225dd72015-08-04 17:08:05 +0200345 parser.add_option("-q", "--quiet",
346 dest="quiet",
347 action="store_true",
348 help="suppress non error messages",
349 default=False)
cliechtif1c882c2009-07-23 02:30:06 +0000350
Chris Liechti7225dd72015-08-04 17:08:05 +0200351 parser.add_option("-o", "--logfile",
352 dest="log_file",
353 help="write messages file instead of stdout",
354 default=None,
355 metavar="FILE")
cliechtif1c882c2009-07-23 02:30:06 +0000356
Chris Liechti7225dd72015-08-04 17:08:05 +0200357 parser.add_option("-d", "--daemon",
358 dest="daemonize",
359 action="store_true",
360 help="start as daemon",
361 default=False)
cliechtif1c882c2009-07-23 02:30:06 +0000362
Chris Liechti7225dd72015-08-04 17:08:05 +0200363 parser.add_option("", "--pidfile",
364 dest="pid_file",
365 help="specify a name for the PID file",
366 default=None,
367 metavar="FILE")
cliechtif1c882c2009-07-23 02:30:06 +0000368
Chris Liechti7225dd72015-08-04 17:08:05 +0200369 parser.add_option("-p", "--port",
370 dest="base_port",
371 help="specify lowest TCP port number (default: %default)",
372 default=7000,
373 type='int',
374 metavar="PORT")
375
376 parser.add_option("", "--ports-regex",
377 dest="ports_regex",
378 help="specify a regex to search against the serial devices and their descriptions (default: %default)",
379 default='/dev/ttyUSB[0-9]+',
380 metavar="REGEX")
Chris Liechti6e683ed2015-08-04 16:59:04 +0200381
cliechtif1c882c2009-07-23 02:30:06 +0000382 (options, args) = parser.parse_args()
383
384 # redirect output if specified
385 if options.log_file is not None:
386 class WriteFlushed:
387 def __init__(self, fileobj):
388 self.fileobj = fileobj
389 def write(self, s):
390 self.fileobj.write(s)
391 self.fileobj.flush()
392 def close(self):
393 self.fileobj.close()
394 sys.stdout = sys.stderr = WriteFlushed(open(options.log_file, 'a'))
395 # atexit.register(lambda: sys.stdout.close())
396
397 if options.daemonize:
398 # if running as daemon is requested, do the fork magic
399 # options.quiet = True
400 import pwd
401 # do the UNIX double-fork magic, see Stevens' "Advanced
402 # Programming in the UNIX Environment" for details (ISBN 0201563177)
403 try:
404 pid = os.fork()
405 if pid > 0:
406 # exit first parent
407 sys.exit(0)
Chris Liechti4caf6a52015-08-04 01:07:45 +0200408 except OSError as e:
cliechtif1c882c2009-07-23 02:30:06 +0000409 sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
410 sys.exit(1)
411
412 # decouple from parent environment
413 os.chdir("/") # don't prevent unmounting....
414 os.setsid()
415 os.umask(0)
416
417 # do second fork
418 try:
419 pid = os.fork()
420 if pid > 0:
421 # exit from second parent, print eventual PID before
422 # print "Daemon PID %d" % pid
423 if options.pid_file is not None:
424 open(options.pid_file,'w').write("%d"%pid)
425 sys.exit(0)
Chris Liechti4caf6a52015-08-04 01:07:45 +0200426 except OSError as e:
cliechtif1c882c2009-07-23 02:30:06 +0000427 sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
428 sys.exit(1)
429
430 if options.log_file is None:
431 import syslog
432 syslog.openlog("serial port publisher")
433 # redirect output to syslog
434 class WriteToSysLog:
435 def __init__(self):
436 self.buffer = ''
437 def write(self, s):
438 self.buffer += s
439 if '\n' in self.buffer:
440 output, self.buffer = self.buffer.split('\n', 1)
441 syslog.syslog(output)
442 def flush(self):
443 syslog.syslog(self.buffer)
444 self.buffer = ''
445 def close(self):
446 self.flush()
447 sys.stdout = sys.stderr = WriteToSysLog()
448
449 # ensure the that the daemon runs a normal user, if run as root
450 #if os.getuid() == 0:
451 # name, passwd, uid, gid, desc, home, shell = pwd.getpwnam('someuser')
452 # os.setgid(gid) # set group first
453 # os.setuid(uid) # set user
454
455 # keep the published stuff in a dictionary
456 published = {}
cliechtif1c882c2009-07-23 02:30:06 +0000457 # get a nice hostname
458 hostname = socket.gethostname()
459
460 def unpublish(forwarder):
461 """when forwarders die, we need to unregister them"""
462 try:
463 del published[forwarder.device]
464 except KeyError:
465 pass
466 else:
Chris Liechti4caf6a52015-08-04 01:07:45 +0200467 if not options.quiet: print("unpublish: %s" % (forwarder))
cliechtif1c882c2009-07-23 02:30:06 +0000468
469 alive = True
470 next_check = 0
471 # main loop
472 while alive:
473 try:
474 # if it is time, check for serial port devices
475 now = time.time()
476 if now > next_check:
477 next_check = now + 5
Chris Liechti6e683ed2015-08-04 16:59:04 +0200478 connected = [d for d, p, i in serial.tools.list_ports.grep(options.ports_regex)]
479 # Handle devices that are published, but no longer connected
480 for device in set(published).difference(connected):
481 if not options.quiet: print("unpublish: %s" % (published[device]))
482 unpublish(published[device])
483 # Handle devices that are connected but not yet published
484 for device in set(connected).difference(published):
485 # Find the first available port, starting from 7000
Chris Liechti7225dd72015-08-04 17:08:05 +0200486 port = options.base_port
Chris Liechti6e683ed2015-08-04 16:59:04 +0200487 ports_in_use = [f.network_port for f in published.values()]
488 while port in ports_in_use:
489 port += 1
490 published[device] = Forwarder(
491 device,
492 "%s on %s" % (device, hostname),
493 port,
494 on_close=unpublish
495 )
496 if not options.quiet: print("publish: %s" % (published[device]))
497 published[device].open()
cliechtif1c882c2009-07-23 02:30:06 +0000498
499 # select_start = time.time()
500 read_map = {}
501 write_map = {}
502 error_map = {}
503 for publisher in published.values():
504 publisher.update_select_maps(read_map, write_map, error_map)
505 try:
506 readers, writers, errors = select.select(
507 read_map.keys(),
508 write_map.keys(),
509 error_map.keys(),
510 5
511 )
Chris Liechti4caf6a52015-08-04 01:07:45 +0200512 except select.error as err:
cliechtif1c882c2009-07-23 02:30:06 +0000513 if err[0] != EINTR:
514 raise
515 # select_end = time.time()
516 # print "select used %.3f s" % (select_end - select_start)
517 for reader in readers:
518 read_map[reader]()
519 for writer in writers:
520 write_map[writer]()
521 for error in errors:
522 error_map[error]()
523 # print "operation used %.3f s" % (time.time() - select_end)
524 except KeyboardInterrupt:
525 alive = False
Chris Liechti7225dd72015-08-04 17:08:05 +0200526 sys.stdout.write('\n')
cliechtif1c882c2009-07-23 02:30:06 +0000527 except SystemExit:
528 raise
529 except:
cliechtie67c1f42009-09-10 15:07:44 +0000530 #~ raise
cliechtif1c882c2009-07-23 02:30:06 +0000531 traceback.print_exc()