blob: 91de06334bf7819c656c33935054fe60125a4a4d [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"""
12import sys, os, time
cliechti32c10332009-08-05 13:23:43 +000013import traceback
cliechtif1c882c2009-07-23 02:30:06 +000014import socket
15import select
cliechti32c10332009-08-05 13:23:43 +000016
cliechtif1c882c2009-07-23 02:30:06 +000017import serial
cliechti32c10332009-08-05 13:23:43 +000018import serial.rfc2217
19
cliechtif1c882c2009-07-23 02:30:06 +000020import avahi
21import dbus
22
23class ZeroconfService:
24 """\
25 A simple class to publish a network service with zeroconf using avahi.
26 """
27
28 def __init__(self, name, port, stype="_http._tcp",
29 domain="", host="", text=""):
30 self.name = name
31 self.stype = stype
32 self.domain = domain
33 self.host = host
34 self.port = port
35 self.text = text
36 self.group = None
37
38 def publish(self):
39 bus = dbus.SystemBus()
40 server = dbus.Interface(
41 bus.get_object(
42 avahi.DBUS_NAME,
43 avahi.DBUS_PATH_SERVER
44 ),
45 avahi.DBUS_INTERFACE_SERVER
46 )
47
48 g = dbus.Interface(
49 bus.get_object(
50 avahi.DBUS_NAME,
51 server.EntryGroupNew()
52 ),
53 avahi.DBUS_INTERFACE_ENTRY_GROUP
54 )
55
56 g.AddService(avahi.IF_UNSPEC, avahi.PROTO_UNSPEC, dbus.UInt32(0),
57 self.name, self.stype, self.domain, self.host,
58 dbus.UInt16(self.port), self.text)
59
60 g.Commit()
61 self.group = g
62
63 def unpublish(self):
64 if self.group is not None:
65 self.group.Reset()
66 self.group = None
67
68 def __str__(self):
69 return "%r @ %s:%s (%s)" % (self.name, self.host, self.port, self.stype)
70
71
72
73class Forwarder(ZeroconfService):
74 """\
75 Single port serial<->TCP/IP forarder that depends on an external select
cliechti32c10332009-08-05 13:23:43 +000076 loop.
77 - Buffers for serial -> network and network -> serial
78 - RFC 2217 state
79 - Zeroconf publish/unpublish on open/close.
cliechtif1c882c2009-07-23 02:30:06 +000080 """
81
82 def __init__(self, device, name, network_port, on_close=None):
83 ZeroconfService.__init__(self, name, network_port, stype='_serial_port._tcp')
84 self.alive = False
85 self.network_port = network_port
86 self.on_close = on_close
87 self.device = device
88 self.serial = serial.Serial()
89 self.serial.port = device
90 self.serial.baudrate = 115200
91 self.serial.timeout = 0
92 self.socket = None
93 self.server_socket = None
cliechti32c10332009-08-05 13:23:43 +000094 self.rfc2217 = None # instantiate later, when connecting
cliechtif1c882c2009-07-23 02:30:06 +000095
96 def __del__(self):
97 try:
98 if self.alive: self.close()
99 except:
100 pass # XXX errors on shutdown
101
102 def open(self):
103 """open serial port, start network server and publish service"""
104 self.buffer_net2ser = ''
105 self.buffer_ser2net = ''
106
107 # open serial port
108 try:
109 self.serial.open()
110 self.serial.setRTS(False)
111 except Exception, msg:
112 self.handle_serial_error(msg)
113
cliechtid9a06ce2009-08-10 01:30:53 +0000114 self.serial_settings_backup = self.serial.getSettingsDict()
115
cliechtif1c882c2009-07-23 02:30:06 +0000116 # start the socket server
117 self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
118 self.server_socket.setsockopt(
119 socket.SOL_SOCKET,
120 socket.SO_REUSEADDR,
121 self.server_socket.getsockopt(
122 socket.SOL_SOCKET,
123 socket.SO_REUSEADDR
124 ) | 1
125 )
126 self.server_socket.setblocking(0)
127 try:
128 self.server_socket.bind( ('', self.network_port) )
129 self.server_socket.listen(1)
130 except socket.error, msg:
131 self.handle_server_error()
132 #~ raise
133 if not options.quiet:
134 print "%s: Waiting for connection on %s..." % (self.device, self.network_port)
135
136 # zeroconfig
137 self.publish()
138
139 # now we are ready
140 self.alive = True
141
142 def close(self):
143 """Close all resources and unpublish service"""
144 if not options.quiet:
145 print "%s: closing..." % (self.device, )
146 self.alive = False
147 self.unpublish()
148 if self.server_socket: self.server_socket.close()
149 if self.socket:
150 self.handle_disconnect()
151 self.serial.close()
152 if self.on_close is not None:
153 # ensure it is only called once
154 callback = self.on_close
155 self.on_close = None
156 callback(self)
157
cliechti32c10332009-08-05 13:23:43 +0000158 def write(self, data):
159 """the write method is used by serial.rfc2217.PortManager. it has to
160 write to the network."""
161 self.buffer_ser2net += data
162
cliechtif1c882c2009-07-23 02:30:06 +0000163 def update_select_maps(self, read_map, write_map, error_map):
164 """Update dictionaries for select call. insert fd->callback mapping"""
165 if self.alive:
166 # always handle serial port reads
167 read_map[self.serial] = self.handle_serial_read
168 error_map[self.serial] = self.handle_serial_error
169 # handle serial port writes if buffer is not empty
170 if self.buffer_net2ser:
171 write_map[self.serial] = self.handle_serial_write
172 # handle network
173 if self.socket is not None:
174 # handle socket if connected
175 # only read from network if the internal buffer is not
176 # already filled. the TCP flow control will hold back data
177 if len(self.buffer_net2ser) < 2048:
178 read_map[self.socket] = self.handle_socket_read
179 # only check for write readiness when there is data
180 if self.buffer_ser2net:
181 write_map[self.socket] = self.handle_socket_write
182 error_map[self.socket] = self.handle_socket_error
183 else:
184 # no connection, ensure clear buffer
185 self.buffer_ser2net = ''
186 # check the server socket
187 read_map[self.server_socket] = self.handle_connect
188 error_map[self.server_socket] = self.handle_server_error
189
190
191 def handle_serial_read(self):
192 """Reading from serial port"""
193 try:
194 data = os.read(self.serial.fileno(), 1024)
195 if data:
196 # store data in buffer if there is a client connected
197 if self.socket is not None:
cliechti32c10332009-08-05 13:23:43 +0000198 # escape outgoing data when needed (Telnet IAC (0xff) character)
199 if self.rfc2217:
200 data = serial.to_bytes(self.rfc2217.escape(data))
cliechtif1c882c2009-07-23 02:30:06 +0000201 self.buffer_ser2net += data
202 else:
203 self.handle_serial_error()
204 except Exception, msg:
205 self.handle_serial_error(msg)
206
207 def handle_serial_write(self):
208 """Writing to serial port"""
209 try:
210 # write a chunk
211 n = os.write(self.serial.fileno(), self.buffer_net2ser)
212 # and see how large that chunk was, remove that from buffer
213 self.buffer_net2ser = self.buffer_net2ser[n:]
214 except Exception, msg:
215 self.handle_serial_error(msg)
216
217 def handle_serial_error(self, error=None):
218 """Serial port error"""
219 # terminate connection
220 self.close()
221
222 def handle_socket_read(self):
223 """Read from socket"""
224 try:
225 # read a chunk from the serial port
226 data = self.socket.recv(1024)
227 if data:
cliechti32c10332009-08-05 13:23:43 +0000228 # Process RFC 2217 stuff when enabled
229 if self.rfc2217:
230 data = serial.to_bytes(self.rfc2217.filter(data))
cliechtif1c882c2009-07-23 02:30:06 +0000231 # add data to buffer
232 self.buffer_net2ser += data
233 else:
234 # empty read indicates disconnection
235 self.handle_disconnect()
236 except socket.error:
237 self.handle_socket_error()
238
239 def handle_socket_write(self):
240 """Write to socket"""
241 try:
242 # write a chunk
243 count = self.socket.send(self.buffer_ser2net)
244 # and remove the sent data from the buffer
245 self.buffer_ser2net = self.buffer_ser2net[count:]
246 except socket.error:
247 self.handle_socket_error()
248
249 def handle_socket_error(self):
250 """Socket connection fails"""
251 self.handle_disconnect()
252
253 def handle_connect(self):
254 """Server socket gets a connection"""
255 # accept a connection in any case, close connection
256 # below if already busy
257 connection, addr = self.server_socket.accept()
258 if self.socket is None:
259 self.socket = connection
260 self.socket.setblocking(0)
cliechtia35cad42009-08-10 20:57:48 +0000261 self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
cliechtif1c882c2009-07-23 02:30:06 +0000262 if not options.quiet:
cliechtid982f792009-08-10 22:19:09 +0000263 print '%s: Connected by %s:%s' % (self.device, addr[0], addr[1])
cliechtid9a06ce2009-08-10 01:30:53 +0000264 self.serial.setRTS(True)
265 self.serial.setDTR(True)
cliechti568dd3c2009-08-17 21:39:47 +0000266 self.rfc2217 = serial.rfc2217.PortManager(self.serial, self)
cliechtif1c882c2009-07-23 02:30:06 +0000267 else:
268 # reject connection if there is already one
269 connection.close()
270 if not options.quiet:
271 print '%s: Rejecting connect from %s:%s' % (self.device, addr[0], addr[1])
272
273 def handle_server_error(self):
cliechti7aed8332009-08-05 14:19:31 +0000274 """Socket server fails"""
cliechtif1c882c2009-07-23 02:30:06 +0000275 self.close()
276
277 def handle_disconnect(self):
278 """Socket gets disconnected"""
cliechtid9a06ce2009-08-10 01:30:53 +0000279 # signal disconnected terminal with control lines
cliechtie67c1f42009-09-10 15:07:44 +0000280 try:
281 self.serial.setRTS(False)
282 self.serial.setDTR(False)
283 finally:
284 # restore original port configuration in case it was changed
285 self.serial.applySettingsDict(self.serial_settings_backup)
286 # stop RFC 2217 state machine
287 self.rfc2217 = None
288 # clear send buffer
289 self.buffer_ser2net = ''
290 # close network connection
291 if self.socket is not None:
292 self.socket.close()
293 self.socket = None
294 if not options.quiet:
295 print '%s: Disconnected' % self.device
cliechtif1c882c2009-07-23 02:30:06 +0000296
297
298def test():
299 service = ZeroconfService(name="TestService", port=3000)
300 service.publish()
301 raw_input("Press any key to unpublish the service ")
302 service.unpublish()
303
304
305if __name__ == '__main__':
306 import optparse
307
308 parser = optparse.OptionParser(usage="""\
309%prog [options]
310
311Announce the existence of devices using zeroconf and provide
cliechti32c10332009-08-05 13:23:43 +0000312a TCP/IP <-> serial port gateway (implements RFC 2217).
cliechtif1c882c2009-07-23 02:30:06 +0000313
314Note that the TCP/IP server is not protected. Everyone can connect
315to it!
316
317If running as daemon, write to syslog. Otherwise write to stdout.
318""")
319
320 parser.add_option("-q", "--quiet", dest="quiet", action="store_true",
321 help="suppress non error messages", default=False)
322
323 parser.add_option("-o", "--logfile", dest="log_file",
324 help="write messages file instead of stdout", default=None, metavar="FILE")
325
326 parser.add_option("-d", "--daemon", dest="daemonize", action="store_true",
327 help="start as daemon", default=False)
328
329 parser.add_option("", "--pidfile", dest="pid_file",
330 help="specify a name for the PID file", default=None, metavar="FILE")
331
332 (options, args) = parser.parse_args()
333
334 # redirect output if specified
335 if options.log_file is not None:
336 class WriteFlushed:
337 def __init__(self, fileobj):
338 self.fileobj = fileobj
339 def write(self, s):
340 self.fileobj.write(s)
341 self.fileobj.flush()
342 def close(self):
343 self.fileobj.close()
344 sys.stdout = sys.stderr = WriteFlushed(open(options.log_file, 'a'))
345 # atexit.register(lambda: sys.stdout.close())
346
347 if options.daemonize:
348 # if running as daemon is requested, do the fork magic
349 # options.quiet = True
350 import pwd
351 # do the UNIX double-fork magic, see Stevens' "Advanced
352 # Programming in the UNIX Environment" for details (ISBN 0201563177)
353 try:
354 pid = os.fork()
355 if pid > 0:
356 # exit first parent
357 sys.exit(0)
358 except OSError, e:
359 sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
360 sys.exit(1)
361
362 # decouple from parent environment
363 os.chdir("/") # don't prevent unmounting....
364 os.setsid()
365 os.umask(0)
366
367 # do second fork
368 try:
369 pid = os.fork()
370 if pid > 0:
371 # exit from second parent, print eventual PID before
372 # print "Daemon PID %d" % pid
373 if options.pid_file is not None:
374 open(options.pid_file,'w').write("%d"%pid)
375 sys.exit(0)
376 except OSError, e:
377 sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
378 sys.exit(1)
379
380 if options.log_file is None:
381 import syslog
382 syslog.openlog("serial port publisher")
383 # redirect output to syslog
384 class WriteToSysLog:
385 def __init__(self):
386 self.buffer = ''
387 def write(self, s):
388 self.buffer += s
389 if '\n' in self.buffer:
390 output, self.buffer = self.buffer.split('\n', 1)
391 syslog.syslog(output)
392 def flush(self):
393 syslog.syslog(self.buffer)
394 self.buffer = ''
395 def close(self):
396 self.flush()
397 sys.stdout = sys.stderr = WriteToSysLog()
398
399 # ensure the that the daemon runs a normal user, if run as root
400 #if os.getuid() == 0:
401 # name, passwd, uid, gid, desc, home, shell = pwd.getpwnam('someuser')
402 # os.setgid(gid) # set group first
403 # os.setuid(uid) # set user
404
405 # keep the published stuff in a dictionary
406 published = {}
407 # prepare list of device names (hard coded)
408 device_list = ['/dev/ttyUSB%d' % p for p in range(8)]
409 # get a nice hostname
410 hostname = socket.gethostname()
411
412 def unpublish(forwarder):
413 """when forwarders die, we need to unregister them"""
414 try:
415 del published[forwarder.device]
416 except KeyError:
417 pass
418 else:
419 if not options.quiet: print "unpublish: %s" % (forwarder)
420
421 alive = True
422 next_check = 0
423 # main loop
424 while alive:
425 try:
426 # if it is time, check for serial port devices
427 now = time.time()
428 if now > next_check:
429 next_check = now + 5
430 # check each device
431 for device in device_list:
432 # if it appeared
433 if os.path.exists(device):
434 if device not in published:
435 num = int(device[-1])
436 published[device] = Forwarder(
437 device,
438 "%s on %s" % (device, hostname),
439 7000+num,
440 on_close=unpublish
441 )
442 if not options.quiet: print "publish: %s" % (published[device])
443 published[device].open()
444 else:
445 # or when it disappeared
446 if device in published:
447 if not options.quiet: print "unpublish: %s" % (published[device])
448 published[device].close()
449 try:
450 del published[device]
451 except KeyError:
452 pass
453
454 # select_start = time.time()
455 read_map = {}
456 write_map = {}
457 error_map = {}
458 for publisher in published.values():
459 publisher.update_select_maps(read_map, write_map, error_map)
460 try:
461 readers, writers, errors = select.select(
462 read_map.keys(),
463 write_map.keys(),
464 error_map.keys(),
465 5
466 )
467 except select.error, err:
468 if err[0] != EINTR:
469 raise
470 # select_end = time.time()
471 # print "select used %.3f s" % (select_end - select_start)
472 for reader in readers:
473 read_map[reader]()
474 for writer in writers:
475 write_map[writer]()
476 for error in errors:
477 error_map[error]()
478 # print "operation used %.3f s" % (time.time() - select_end)
479 except KeyboardInterrupt:
480 alive = False
481 except SystemExit:
482 raise
483 except:
cliechtie67c1f42009-09-10 15:07:44 +0000484 #~ raise
cliechtif1c882c2009-07-23 02:30:06 +0000485 traceback.print_exc()