Import my open source port.py project.

From commit 635b0c4a4ffa098a3e62a2967a4b0ad198a6ca74.

Change-Id: I1f228462b947f3fa8e3e3904d2d21a3a44b6cfcf
diff --git a/portsh/.gitignore b/portsh/.gitignore
new file mode 100644
index 0000000..f3d74a9
--- /dev/null
+++ b/portsh/.gitignore
@@ -0,0 +1,2 @@
+*.pyc
+*~
diff --git a/portsh/LICENSE b/portsh/LICENSE
new file mode 100644
index 0000000..432ecf1
--- /dev/null
+++ b/portsh/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2011-2012 Avery Pennarun and port.py contributors.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   1. Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+   2. Redistributions in binary form must reproduce the above copyright
+      notice, this list of conditions and the following disclaimer in
+      the documentation and/or other materials provided with the
+      distribution.
+
+THIS SOFTWARE IS PROVIDED BY AVERY PENNARUN ``AS IS'' AND ANY
+EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/portsh/Makefile b/portsh/Makefile
new file mode 100644
index 0000000..8b71be4
--- /dev/null
+++ b/portsh/Makefile
@@ -0,0 +1,5 @@
+all:
+	@echo "Nothing to do."
+
+clean:
+	rm -f *~ .*~ *.pyc
diff --git a/portsh/options.py b/portsh/options.py
new file mode 100644
index 0000000..9bcead7
--- /dev/null
+++ b/portsh/options.py
@@ -0,0 +1,271 @@
+# Copyright 2011 Avery Pennarun and options.py contributors.
+# All rights reserved.
+#
+# (This license applies to this file but not necessarily the other files in
+# this package.)
+# 
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+# 
+#    1. Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+# 
+#    2. Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in
+#       the documentation and/or other materials provided with the
+#       distribution.
+# 
+# THIS SOFTWARE IS PROVIDED BY AVERY PENNARUN ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+"""Command-line options parser.
+With the help of an options spec string, easily parse command-line options.
+
+An options spec is made up of two parts, separated by a line with two dashes.
+The first part is the synopsis of the command and the second one specifies
+options, one per line.
+
+Each non-empty line in the synopsis gives a set of options that can be used
+together.
+
+Option flags must be at the begining of the line and multiple flags are
+separated by commas. Usually, options have a short, one character flag, and a
+longer one, but the short one can be omitted.
+
+Long option flags are used as the option's key for the OptDict produced when
+parsing options.
+
+When the flag definition is ended with an equal sign, the option takes one
+string as an argument. Otherwise, the option does not take an argument and
+corresponds to a boolean flag that is true when the option is given on the
+command line.
+
+The option's description is found at the right of its flags definition, after
+one or more spaces. The description ends at the end of the line. If the
+description contains text enclosed in square brackets, the enclosed text will
+be used as the option's default value.
+
+Options can be put in different groups. Options in the same group must be on
+consecutive lines. Groups are formed by inserting a line that begins with a
+space. The text on that line will be output after an empty line.
+"""
+import sys, os, textwrap, getopt, re, struct
+
+class OptDict:
+    """Dictionary that exposes keys as attributes.
+
+    Keys can bet set or accessed with a "no-" or "no_" prefix to negate the
+    value.
+    """
+    def __init__(self):
+        self._opts = {}
+
+    def __setitem__(self, k, v):
+        if k.startswith('no-') or k.startswith('no_'):
+            k = k[3:]
+            v = not v
+        self._opts[k] = v
+
+    def __getitem__(self, k):
+        if k.startswith('no-') or k.startswith('no_'):
+            return not self._opts[k[3:]]
+        return self._opts[k]
+
+    def __getattr__(self, k):
+        return self[k]
+
+
+def _default_onabort(msg):
+    sys.exit(97)
+
+
+def _intify(v):
+    try:
+        vv = int(v or '')
+        if str(vv) == v:
+            return vv
+    except ValueError:
+        pass
+    return v
+
+
+def _atoi(v):
+    try:
+        return int(v or 0)
+    except ValueError:
+        return 0
+
+
+def _remove_negative_kv(k, v):
+    if k.startswith('no-') or k.startswith('no_'):
+        return k[3:], not v
+    return k,v
+
+def _remove_negative_k(k):
+    return _remove_negative_kv(k, None)[0]
+
+
+def _tty_width():
+    s = struct.pack("HHHH", 0, 0, 0, 0)
+    try:
+        import fcntl, termios
+        s = fcntl.ioctl(sys.stderr.fileno(), termios.TIOCGWINSZ, s)
+    except (IOError, ImportError):
+        return _atoi(os.environ.get('WIDTH')) or 70
+    (ysize,xsize,ypix,xpix) = struct.unpack('HHHH', s)
+    return xsize or 70
+
+
+class Options:
+    """Option parser.
+    When constructed, a string called an option spec must be given. It
+    specifies the synopsis and option flags and their description.  For more
+    information about option specs, see the docstring at the top of this file.
+
+    Two optional arguments specify an alternative parsing function and an
+    alternative behaviour on abort (after having output the usage string).
+
+    By default, the parser function is getopt.gnu_getopt, and the abort
+    behaviour is to exit the program.
+    """
+    def __init__(self, optspec, optfunc=getopt.gnu_getopt,
+                 onabort=_default_onabort):
+        self.optspec = optspec
+        self._onabort = onabort
+        self.optfunc = optfunc
+        self._aliases = {}
+        self._shortopts = 'h?'
+        self._longopts = ['help', 'usage']
+        self._hasparms = {}
+        self._defaults = {}
+        self._usagestr = self._gen_usage()
+
+    def _gen_usage(self):
+        out = []
+        lines = self.optspec.strip().split('\n')
+        lines.reverse()
+        first_syn = True
+        while lines:
+            l = lines.pop()
+            if l == '--': break
+            out.append('%s: %s\n' % (first_syn and 'usage' or '   or', l))
+            first_syn = False
+        out.append('\n')
+        last_was_option = False
+        while lines:
+            l = lines.pop()
+            if l.startswith(' '):
+                out.append('%s%s\n' % (last_was_option and '\n' or '',
+                                       l.lstrip()))
+                last_was_option = False
+            elif l:
+                (flags, extra) = l.split(' ', 1)
+                extra = extra.strip()
+                if flags.endswith('='):
+                    flags = flags[:-1]
+                    has_parm = 1
+                else:
+                    has_parm = 0
+                g = re.search(r'\[([^\]]*)\]$', extra)
+                if g:
+                    defval = g.group(1)
+                else:
+                    defval = None
+                flagl = flags.split(',')
+                flagl_nice = []
+                for _f in flagl:
+                    f,dvi = _remove_negative_kv(_f, _intify(defval))
+                    self._aliases[f] = _remove_negative_k(flagl[0])
+                    self._hasparms[f] = has_parm
+                    self._defaults[f] = dvi
+                    if f == '#':
+                        self._shortopts += '0123456789'
+                        flagl_nice.append('-#')
+                    elif len(f) == 1:
+                        self._shortopts += f + (has_parm and ':' or '')
+                        flagl_nice.append('-' + f)
+                    else:
+                        f_nice = re.sub(r'\W', '_', f)
+                        self._aliases[f_nice] = _remove_negative_k(flagl[0])
+                        self._longopts.append(f + (has_parm and '=' or ''))
+                        self._longopts.append('no-' + f)
+                        flagl_nice.append('--' + _f)
+                flags_nice = ', '.join(flagl_nice)
+                if has_parm:
+                    flags_nice += ' ...'
+                prefix = '    %-20s  ' % flags_nice
+                argtext = '\n'.join(textwrap.wrap(extra, width=_tty_width(),
+                                                initial_indent=prefix,
+                                                subsequent_indent=' '*28))
+                out.append(argtext + '\n')
+                last_was_option = True
+            else:
+                out.append('\n')
+                last_was_option = False
+        return ''.join(out).rstrip() + '\n'
+
+    def usage(self, msg=""):
+        """Print usage string to stderr and abort."""
+        sys.stderr.write(self._usagestr)
+        if msg:
+            sys.stderr.write(msg)
+        e = self._onabort and self._onabort(msg) or None
+        if e:
+            raise e
+
+    def fatal(self, msg):
+        """Print an error message to stderr and abort with usage string."""
+        msg = '\nerror: %s\n' % msg
+        return self.usage(msg)
+
+    def parse(self, args):
+        """Parse a list of arguments and return (options, flags, extra).
+
+        In the returned tuple, "options" is an OptDict with known options,
+        "flags" is a list of option flags that were used on the command-line,
+        and "extra" is a list of positional arguments.
+        """
+        try:
+            (flags,extra) = self.optfunc(args, self._shortopts, self._longopts)
+        except getopt.GetoptError, e:
+            self.fatal(e)
+
+        opt = OptDict()
+
+        for k,v in self._defaults.iteritems():
+            k = self._aliases[k]
+            opt[k] = v
+
+        for (k,v) in flags:
+            k = k.lstrip('-')
+            if k in ('h', '?', 'help', 'usage'):
+                self.usage()
+            if k.startswith('no-'):
+                k = self._aliases[k[3:]]
+                v = 0
+            elif (self._aliases.get('#') and
+                  k in ('0','1','2','3','4','5','6','7','8','9')):
+                v = int(k)  # guaranteed to be exactly one digit
+                k = self._aliases['#']
+                opt['#'] = v
+            else:
+                k = self._aliases[k]
+                if not self._hasparms[k]:
+                    assert(v == '')
+                    v = (opt._opts.get(k) or 0) + 1
+                else:
+                    v = _intify(v)
+            opt[k] = v
+        for (f1,f2) in self._aliases.iteritems():
+            opt[f1] = opt._opts.get(f2)
+        return (opt,flags,extra)
diff --git a/portsh/port b/portsh/port
new file mode 120000
index 0000000..99c1f7f
--- /dev/null
+++ b/portsh/port
@@ -0,0 +1 @@
+port.py
\ No newline at end of file
diff --git a/portsh/port.py b/portsh/port.py
new file mode 100755
index 0000000..7e8436f
--- /dev/null
+++ b/portsh/port.py
@@ -0,0 +1,270 @@
+#!/usr/bin/env python
+# Copyright 2011-2012 Avery Pennarun and port.py contributors.
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+#    1. Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#
+#    2. Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in
+#       the documentation and/or other materials provided with the
+#       distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+import array
+import errno
+import fcntl
+import os
+import random
+import select
+import sys
+import termios
+import time
+import tty
+import options
+
+optspec = """
+port [options...] <tty>
+--
+s,speed=    the baud rate to use [115200]
+l,limit=    maximum upload rate (for devices with crappy flow control) [9600]
+"""
+
+
+def log(s, *args):
+    if args:
+        ss = s % args
+    else:
+        ss = s
+    sys.stdout.flush()
+    sys.stderr.write(ss.replace('\n', '\r\n'))
+    sys.stderr.flush()
+
+
+class ModemError(Exception):
+    pass
+
+class AlreadyLockedError(Exception):
+    pass
+
+
+def _speedv(speed):
+    try:
+        return termios.__dict__['B%s' % int(speed)]
+    except KeyError:
+        raise ModemError('invalid port speed: %r (try 115200, 57600, etc)'
+                         % speed)
+
+
+def _unlink(path):
+    try:
+        os.unlink(path)
+    except OSError, e:
+        if e.errno == errno.ENOENT:
+            return  # it's deleted, so that's not an error
+        raise
+
+
+class Lock(object):
+    """Represents a unix tty lockfile to prevent overlapping access."""
+
+    def __init__(self, devname):
+        assert '/' not in devname
+        if os.path.exists('/var/lock'):
+            # Linux standard location
+            self.path = '/var/lock/LCK..%s' % devname
+        else:
+            # this is the patch minicom seems to use on MacOS X
+            self.path = '/tmp/LCK..%s' % devname
+        self.lock()
+
+    def __del__(self):
+        self.unlock()
+
+    def read(self):
+        try:
+            return int(open(self.path).read().strip().split()[0])
+        except IOError, e:
+            if e.errno == errno.ENOENT:
+                return None  # not locked
+            else:
+                return 0  # invalid lock
+        except ValueError:
+            return 0
+
+    def _pid_exists(self, pid):
+        assert pid > 0
+        try:
+            os.kill(pid, 0)  # 0 is a signal that always does nothing
+        except OSError, e:
+            if e.errno == errno.EPERM:  # no permission means it exists!
+                return True
+            if e.errno == errno.ESRCH:  # not found
+                return False
+            raise  # any other error is weird, pass it on
+        return True  # no error means it exists
+
+    def _try_lock(self):
+        try:
+            fd = os.open(self.path, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0666)
+        except OSError:
+            return
+        try:
+            os.write(fd, '%s\n' % os.getpid())
+        finally:
+            os.close(fd)
+
+    def lock(self):
+        mypid = os.getpid()
+        for _ in range(10):
+            pid = self.read()
+            if pid == mypid:
+                return
+            elif pid is None:
+                # file did not exist
+                self._try_lock()
+            elif pid > 0 and self._pid_exists(pid):
+                raise AlreadyLockedError('%r locked by pid %d'
+                                         % (self.path, pid))
+            else:
+                # the lock owner died or didn't write a pid.  Cleaning it
+                # creates a race condition.  Delete it only after
+                # double checking.
+                time.sleep(0.2 + 0.2*random.random())
+                pid2 = self.read()
+                if pid2 == pid and (pid == 0 or not self._pid_exists(pid)):
+                    _unlink(self.path)
+                # now loop and try again.  Someone else might be racing with
+                # us, so there's no guarantee we'll get the lock on our
+                # next try.
+        raise AlreadyLockedError('%r lock contention detected' % self.path)
+
+    def unlock(self):
+        if self.read() == os.getpid():
+            _unlink(self.path)
+
+
+class Modem(object):
+    def __init__(self, filename, speed):
+        self.fd = self.tc_orig = None
+        if '/' not in filename and os.path.exists('/dev/%s' % filename):
+            filename = '/dev/%s' % filename
+        self.lock = Lock(os.path.basename(filename))
+        self.fd = os.open(filename, os.O_RDWR | os.O_NONBLOCK)
+        fcntl.fcntl(self.fd, fcntl.F_SETFL,
+                    fcntl.fcntl(self.fd, fcntl.F_GETFL) & ~os.O_NONBLOCK)
+        self.tc_orig = tc = termios.tcgetattr(self.fd)
+        tc[4] = tc[5] = _speedv(speed)
+        tc[2] &= ~(termios.PARENB | termios.PARODD)
+        tc[2] |= termios.CLOCAL
+        termios.tcsetattr(self.fd, termios.TCSADRAIN, tc)
+        tty.setraw(self.fd)
+
+    def __del__(self):
+        self.close()
+
+    def close(self):
+        if self.fd is not None:
+            try:
+                termios.tcsetattr(self.fd, termios.TCSADRAIN, self.tc_orig)
+            except:
+                pass
+            os.close(self.fd)
+
+    def flags(self):
+        bits = [(i, getattr(termios,i))
+                for i in dir(termios)
+                if i.startswith('TIOCM_')]
+        tbuf = array.array('i', [0])
+        fcntl.ioctl(self.fd, termios.TIOCMGET, tbuf, True)
+        out = []
+        for name, bit in sorted(bits):
+            if tbuf[0] & bit:
+                out.append(name[6:])
+        return ', '.join(out)
+
+    def sendbreak(self):
+        termios.tcsendbreak(self.fd, 0)
+
+
+def main():
+    o = options.Options(optspec)
+    (opt, flags, extra) = o.parse(sys.argv[1:])
+    if len(extra) != 1:
+        o.fatal("exactly one tty name expected")
+    filename = extra[0]
+    if opt.limit and opt.limit < 300:
+        o.fatal('--limit should be at least 300 bps')
+    if opt.limit > max(115200, int(opt.speed)):
+        o.fatal('--limit should be no more than --speed')
+
+    tc_stdin_orig = termios.tcgetattr(0)
+    modem = Modem(filename, opt.speed)
+
+    line = ''
+    MAGIC = ['~.', '!.']
+
+    try:
+        tty.setraw(0)
+
+        mflags = None
+        last_out = 0
+        if opt.limit:
+            secs_per_byte = 1.0 / (float(opt.limit) / 10)
+            assert(secs_per_byte < 0.1)
+        log('(Type ~. or !. to exit, or ~b to send BREAK)')
+
+        while 1:
+            newflags = modem.flags()
+            if newflags != mflags:
+                mflags = newflags
+                log('\n(Line Status: %s)\n', mflags)
+
+            r,w,x = select.select([0,modem.fd], [], [])
+            if 0 in r:
+                buf = os.read(0, 1)
+                if buf in '\r\n\x03':
+                    line = ''
+                else:
+                    line += buf
+                if line in MAGIC:
+                    break
+                if line == '~b':
+                    log('(BREAK)')
+                    modem.sendbreak()
+                    line = ''
+                elif len(buf):
+                    os.write(modem.fd, buf)
+                    if opt.limit:
+                        time.sleep(secs_per_byte)
+            if modem.fd in r:
+                buf = os.read(modem.fd, 4096)
+                if len(buf):
+                    os.write(1, buf)
+                if buf == '\0':
+                    log('\n(received NUL byte)\n')
+    finally:
+        termios.tcsetattr(0, termios.TCSANOW, tc_stdin_orig)
+
+
+if __name__ == '__main__':
+    try:
+        main()
+    except AlreadyLockedError, e:
+        sys.stderr.write('error: %s\n' % e)
+        exit(1)
diff --git a/portsh/portsh b/portsh/portsh
new file mode 120000
index 0000000..06d198b
--- /dev/null
+++ b/portsh/portsh
@@ -0,0 +1 @@
+portsh.py
\ No newline at end of file
diff --git a/portsh/portsh.py b/portsh/portsh.py
new file mode 100755
index 0000000..87f98b6
--- /dev/null
+++ b/portsh/portsh.py
@@ -0,0 +1,303 @@
+#!/usr/bin/env python
+# Copyright 2011-2012 Avery Pennarun and port.py contributors.
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+#    1. Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#
+#    2. Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in
+#       the documentation and/or other materials provided with the
+#       distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+def assembler(splitter):
+    import os, select, subprocess, sys, zlib
+
+    zc = zlib.compressobj()
+    zd = zlib.decompressobj()
+    def encode(b):
+        return ((zc.compress(b) + zc.flush(zlib.Z_SYNC_FLUSH))
+                .encode("base64").replace("\n", ""))
+    def decode(b):
+        try:
+            return zd.decompress(b.strip().decode("base64"))
+        except Exception:
+            sys.stderr.write("ERROR base64 decode: %r\n" % b)
+            raise
+
+    cmd = decode(sys.stdin.readline())
+    print "%s-RUNNING" % splitter
+
+    p = subprocess.Popen(cmd, shell=True,
+                         stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+                         stderr=subprocess.PIPE)
+
+    fds = [sys.stdin, p.stdout, p.stderr]
+    while p.stdout in fds or p.stderr in fds:
+        r,w,x = select.select(fds, [], [])
+        if p.stderr in r:
+            b = os.read(p.stderr.fileno(), 4096)
+            if b:
+                print "2 %s" % encode(b)
+            else:
+                fds.remove(p.stderr)
+        elif p.stdout in r:
+            b = os.read(p.stdout.fileno(), 4096)
+            if b:
+                print "1 %s" % encode(b)
+            else:
+                fds.remove(p.stdout)
+        elif sys.stdin in r:
+            b = os.read(sys.stdin.fileno(), 4096)
+            if b:
+                p.stdin.write(decode(b))
+            else:
+                fds.remove(sys.stdin)
+                p.stdin.close()
+    rv = p.wait()
+    print "%s-EXIT-%d" % (splitter, rv)
+# END ASSEMBLER
+# The above is the stage2 assembler that gets run on the remote
+# system. To ensure that syntax errors and exceptions have useful line
+# numbers, keep it at the top of the file.
+
+import re, os, sys, tty, termios, fcntl, select, array, time, uuid, zlib
+import options
+import port
+
+optspec = """
+portsh [options...] <tty> <command string...>
+--
+t,trace     show serial port trace on stderr
+s,speed=    the baud rate to use [115200]
+u,user=     response to 'login:' prompt [root]
+p,password= response to 'Password:' prompt
+"""
+
+
+def log(s, *args):
+    if args:
+        ss = s % args
+    else:
+        ss = s
+    sys.stdout.flush()
+    sys.stderr.write(ss.replace('\n', '\r\n'))
+    sys.stderr.flush()
+
+
+_want_trace = False
+def trace(s):
+    if _want_trace:
+        log('\x1b[35;1m%s\x1b[m' % re.sub(r'\x1b[[\d;]+[a-z]', '', s))
+
+
+class Reader(object):
+    def __init__(self, fd):
+        self.fd = fd
+        self.buf = ''
+
+    def fill(self, timeout):
+        r,w,x = select.select([self.fd], [], [], timeout)
+        if r:
+            nbuf = os.read(self.fd, 4096)
+            if nbuf:
+                trace('(%d)' % len(nbuf))
+                self.buf += nbuf.replace('\r\n', '\n')
+                return nbuf
+        return ''
+
+    def get(self, nbytes):
+        out = self.buf[:nbytes]
+        self.buf = self.buf[nbytes:]
+        return out
+
+    def get_until(self, sep):
+        pos = self.buf.find(sep)
+        if pos >= 0:
+            return self.get(pos + len(sep))
+
+    def get_all(self):
+        return self.get(len(self.buf))
+
+    def lines(self):
+        while 1:
+            line = self.get_until('\n')
+            if not line:
+                break
+            yield line
+
+
+def read_until_idle(fd, start_timeout):
+    timeout = start_timeout
+    buf = ''
+    while 1:
+        r,w,x = select.select([fd], [], [], timeout)
+        if r:
+            nbuf = os.read(fd, 4096)
+            if nbuf:
+                trace('(%d)' % len(nbuf))
+            buf += nbuf
+            timeout = 0.1
+        else:
+            break
+    return buf
+
+
+def get_shell_prompt(fd, user, password):
+    # Send some ctrl-c (SIGINTR) and newlines as a basic terminal reset.
+    os.write(fd, '\x03\x03\x03\r\n')
+    last_was_sh = 0
+    buf = read_until_idle(fd, 0.0)
+    for tries in range(10):
+        trace(buf.replace('\r', ''))
+        bufclean = buf.lower().strip()
+        if bufclean.endswith('login:'):
+            os.write(fd, user + '\n')
+        elif bufclean.endswith('password:'):
+                os.write(fd, password + '\n')
+                trace('(password)')
+        elif ('%s%s' % ('MAGIC', 'STRING')) in buf.replace('\r', ''):
+            # success!
+            trace('(got a shell prompt)\n')
+            return
+        elif (not last_was_sh and
+              (bufclean.endswith('#') or bufclean.endswith('$') or # sh
+               bufclean.endswith('%') or bufclean.endswith('>') or # csh/tcsh
+               '\x1b' in bufclean)):  # fancy ansi characters
+            # probably shell prompt
+            os.write(fd, 'printf MAGIC; printf STRING\r')
+            trace('(shelltest)\n')
+            last_was_sh = 1
+        else:
+            last_was_sh = 0
+            r,w,x = select.select([fd], [], [], 2.0)
+            if not r:
+                # Send some ctrl-c (SIGINTR), ctrl-d (EOF), and
+                #  ctrl-\ (SIGQUIT) to try to exit out of anything
+                #  already running.
+                trace('(prodding)\n')
+                os.write(fd, '\x03\x03\x03\r\n\x04\x04\x04\x1c\x1c\x1c\r\n')
+        buf = read_until_idle(fd, 1.0)
+    raise port.ModemError("couldn't get a shell prompt after 10 tries")
+
+
+def wait_for_string(reader, s):
+    timeout = 10.0
+    for i in range(50):
+        nbuf = reader.fill(timeout)
+        timeout = 1.0
+        trace(nbuf)
+        got = reader.get_until(s)
+        if got:
+            trace('(got %s)' % s)
+            return got[:-len(s)]
+    raise port.ModemError("didn't find %r after 10 tries")
+
+
+PY_SCRIPT1 = r"""
+stty sane; stty -echo; python -Sc '
+import sys, zlib
+print "%s-READY\n" % "SPLITTER";
+b = sys.stdin.readline().strip()
+exec(zlib.decompress(b.decode("base64")))
+assembler("SPLITTER")
+'; printf %s-EXIT-97\\n SPLITTER; stty sane; cat
+"""
+
+def main():
+    o = options.Options(optspec)
+    (opt, flags, extra) = o.parse(sys.argv[1:])
+    if len(extra) < 2:
+        o.fatal("exactly one tty name and a command expected")
+    if opt.trace:
+        global _want_trace
+        _want_trace = opt.trace
+    filename = extra[0]
+    cmd = ' '.join(extra[1:])
+
+    modem = port.Modem(filename, opt.speed)
+    get_shell_prompt(modem.fd, opt.user, opt.password or '')
+
+    splitter = uuid.uuid4().hex
+    reader = Reader(modem.fd)
+
+    zc = zlib.compressobj()
+    zd = zlib.decompressobj()
+    def encode(b):
+        return ((zc.compress(b) + zc.flush(zlib.Z_SYNC_FLUSH))
+                .encode('base64').replace('\n', ''))
+    def decode(b):
+        return zd.decompress(b.strip().decode('base64'))
+
+    os.write(modem.fd,
+             "%s\r" % PY_SCRIPT1.strip().replace('SPLITTER', splitter))
+    wait_for_string(reader, '%s-READY\n' % splitter)
+
+    py_script, junk = open(__file__).read().split('# END ASSEMBLER\n', 1)
+    assert junk
+    cpy_script = zlib.compress(py_script).encode('base64').replace('\n', '')
+    trace('(cpy_script=%d)' % len(cpy_script))
+    os.write(modem.fd, "%s\r" % cpy_script)
+    zc = zlib.compressobj()
+    os.write(modem.fd, "%s\r" % encode(cmd))
+    wait_for_string(reader, '%s-RUNNING\n' % splitter)
+    split_end = '%s-EXIT-' % splitter
+
+    fds = [0, modem.fd]
+    while 1:
+        r,w,x = select.select(fds, [], [])
+        if 0 in r:
+            buf = os.read(0, 128)
+            if len(buf):
+                trace('>>%s' % buf)
+                os.write(modem.fd, encode(buf) + '\n')
+            else:
+                os.write(modem.fd, '\n\x04')  # EOF signal
+                fds.remove(0)
+        if modem.fd in r:
+            nbuf = 1
+            while nbuf:
+                nbuf = reader.fill(0.1)
+                trace(nbuf)
+                for line in reader.lines():
+                    if split_end in line:
+                        pre, rv = line.split(split_end, 1)
+                        assert not pre
+                        trace('(rv=%r)' % rv)
+                        sys.exit(int(rv))
+                    if line.startswith('1 '):
+                        os.write(1, decode(line[2:]))
+                    elif line.startswith('2 '):
+                        os.write(2, decode(line[2:]))
+                    elif (line.startswith('Traceback ') or
+                          line.startswith('ERROR')):
+                        log(line)
+                        while nbuf:
+                            nbuf = reader.fill(1)
+                            log(nbuf)
+                    else:
+                        while nbuf:
+                            nbuf = reader.fill(0.1)
+                            trace(nbuf)
+                        raise port.ModemError('unexpected prefix %r...'
+                                              % line[:15])
+
+
+if __name__ == '__main__':
+    main()