#!/usr/bin/env python3
"""Run bash behind a simulated serial (baud-rate) throttle.

Every byte of shell output -- and of your keyboard input -- is written one byte
at a time, pausing between bytes by the amount implied by a serial line running
at the chosen baud rate. The result is that output scrolls across the screen
character by character, as it would on a real slow serial terminal, instead of
appearing all at once. This makes old VT100/ANSI animations visible again: most
terminal emulators render as fast as the machine allows, so they do not animate
without a throttle.

Usage:
    ./baudshell [BAUD]

BAUD  baud rate to simulate, in bits/second. Default: 9600. Lower is slower.

When the shell exits (type `exit`), the program detects end-of-stream on the
pseudo-terminal and shuts down cleanly, restoring the terminal to its original
mode.
"""
import os
import pty
import sys
import tty
import termios
import select
import time
import subprocess

DEFAULT_BAUD = 9600
SHELL = ["bash", "--norc"]
CHUNK = 4096


def delay_per_char(baud):
    """Seconds to pause between bytes on a serial line at `baud` bits/second.

    A serial byte is 10 bits (1 start + 8 data + 1 stop), so a line at `baud`
    bits/second delivers baud/10 bytes/second, and one byte takes 10/baud
    seconds.
    """
    return 10 / baud


def throttled_copy(src, dst, baud):
    """Copy from fd `src` to fd `dst` one byte at a time, pausing between bytes
    to simulate a serial link at `baud`.

    Returns True when the stream is finished -- `src` reached end-of-stream or
    either end broke -- so the caller can stop. Returns False while more data
    may still flow.
    """
    try:
        data = os.read(src, CHUNK)
    except OSError:
        return True
    if not data:
        return True

    step = delay_per_char(baud)
    for byte in data:
        try:
            os.write(dst, bytes((byte,)))
        except OSError:
            return True
        time.sleep(step)
    return False


def main():
    baud = DEFAULT_BAUD
    if len(sys.argv) > 1:
        try:
            baud = int(sys.argv[1])
        except ValueError:
            sys.stderr.write(f"Invalid baud rate: {sys.argv[1]!r}\n")
            return 2
        if baud <= 0:
            sys.stderr.write(f"Invalid baud rate: {baud} (must be positive)\n")
            return 2

    fd_term = sys.stdin.fileno()

    master, slave = pty.openpty()
    shell = subprocess.Popen(
        SHELL,
        stdin=slave,
        stdout=slave,
        stderr=slave,
        preexec_fn=os.setsid,
        close_fds=True,
    )
    os.close(slave)

    old = termios.tcgetattr(fd_term)
    tty.setraw(fd_term)

    exit_code = 0
    try:
        while True:
            readable, _, _ = select.select([fd_term, master], [], [])

            # Keyboard -> shell.  stdin reaching EOF (terminal closed, or piped
            # input drained) means there is nothing more to feed in.
            if fd_term in readable and throttled_copy(fd_term, master, baud):
                break

            # Shell -> terminal.  The master side reaching EOF means the shell
            # process has exited; stop and fall through to cleanup.
            if master in readable and throttled_copy(master, fd_term, baud):
                break
    finally:
        # Always hand the terminal back in a usable state.
        termios.tcsetattr(fd_term, termios.TCSADRAIN, old)
        if shell.poll() is None:
            shell.terminate()

    # Reap the shell and propagate its exit status.
    exit_code = shell.wait()
    if exit_code is not None and exit_code < 0:
        exit_code = 128 + (-exit_code)   # killed by signal N -> status 128+N
    return exit_code


if __name__ == "__main__":
    sys.exit(main())
