Cameron Coward
Published © CC BY-NC-SA

Termi3 - The Question-Answering Typewriter Is Back

This is the 3rd version of what is probably my most popular project: a vintage typewriter (actually a paper terminal) that answers questions

IntermediateFull instructions provided12 hours175
Termi3 - The Question-Answering Typewriter Is Back

Things used in this project

Hardware components

Arduino UNO Q
Arduino UNO Q
×1
MAX3232 Transceiver
×1

Software apps and online services

Llama 3.2-1B
Arduino App Lab

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)
CNC mill

Story

Read more

Custom parts and enclosures

Enclosure

Schematics

PCB

The custom PCB for the RS232 transceiver

Code

main.py

Python
Main Python script for Linux side
"""
Termi3 - Python Script
Cameron Coward
cameroncoward.com
9/24/2026
"""
import http.client
import json
import re
import socket
import threading

from arduino.app_utils import *  # noqa: F401,F403  (Bridge, App)

# The llama-server runs as a systemd service on the HOST (bound to 0.0.0.0:8080).
# This Python app runs inside an App Lab Docker container, so "127.0.0.1" is the
# container's own loopback  NOT the host. We must reach the host via the Docker
# gateway. Try the standard host aliases / gateway IPs in order and pick the
# first that resolves+connects.
LLM_PORT = 8080
_HOST_CANDIDATES = [
    "host.docker.internal",  # Docker's standard host alias (if enabled)
    "172.19.0.1",            # termi3_default network gateway (observed)
    "172.17.0.1",            # default docker0 bridge gateway (fallback)
    "127.0.0.1",             # last resort (works only if not containerized)
]


def _pick_llm_host() -> str:
    for host in _HOST_CANDIDATES:
        try:
            with socket.create_connection((host, LLM_PORT), timeout=2):
                print(f"[termi3] llama-server reachable at {host}:{LLM_PORT}")
                return host
        except OSError:
            continue
    # Nothing reachable yet; default to the observed gateway and let requests
    # surface the error (server may still be starting).
    print("[termi3] WARNING: no llama-server host reachable yet; using gateway")
    return "172.19.0.1"


LLM_HOST = _pick_llm_host()
LLM_PATH = "/v1/chat/completions"

# Reuse a single HTTP connection across questions (avoids a fresh TCP handshake
# per request). Guarded by a lock since Bridge calls may arrive on a thread.
_conn_lock = threading.Lock()
_conn = None


def _get_conn():
    global _conn
    if _conn is None:
        _conn = http.client.HTTPConnection(LLM_HOST, LLM_PORT, timeout=HTTP_TIMEOUT)
    return _conn


def _reset_conn():
    global _conn
    try:
        if _conn is not None:
            _conn.close()
    except Exception:  # noqa: BLE001
        pass
    _conn = None

# Concise/factual system prompt (no personality). Kept SHORT on purpose: fewer
# prompt tokens = faster prompt processing and a smaller, more stable prefix
# cache. Measured ~2.1-2.7s time-to-first-token vs ~5-10s with the long prompt.
SYSTEM_PROMPT = (
    "Answer the question concisely and factually in as few words as possible, "
    "ideally one short sentence. No commentary or extra words."
)

MAX_TOKENS = 64
TEMPERATURE = 0.3
HTTP_TIMEOUT = 30  # seconds; the MCU-side Bridge timeout should exceed this

# --- Output sanitization for the Silent 700 (uppercase ASCII printer) -------
_MD = re.compile(r"[*_`#>]+")
_UNICODE = {
    "\u2019": "'", "\u2018": "'", "\u201c": '"', "\u201d": '"',
    "\u2013": "-", "\u2014": "-", "\u2026": "...", "\u00a0": " ",
}


def sanitize(text: str) -> str:
    text = _MD.sub("", text)
    for k, v in _UNICODE.items():
        text = text.replace(k, v)
    text = text.encode("ascii", "ignore").decode("ascii")
    # Collapse whitespace/newlines into single spaces (printer handles CR/LF
    # via the MCU; the answer body should be one clean line).
    text = " ".join(text.split())
    return text.strip()


def _query_llm(question: str) -> str:
    body = {
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": question},
        ],
        "max_tokens": MAX_TOKENS,
        "temperature": TEMPERATURE,
        "cache_prompt": True,
    }
    data = json.dumps(body).encode()
    headers = {"Content-Type": "application/json"}

    with _conn_lock:
        # One retry: if the persisted connection went stale, reset and redo.
        for attempt in (1, 2):
            try:
                conn = _get_conn()
                conn.request("POST", LLM_PATH, body=data, headers=headers)
                resp = conn.getresponse()
                raw = resp.read()
                payload = json.loads(raw)
                return payload["choices"][0]["message"]["content"]
            except (http.client.HTTPException, OSError):
                _reset_conn()
                if attempt == 2:
                    raise


def ask(question: str) -> str:
    """Called by the MCU over the Bridge. Returns a printable answer line."""
    q = (question or "").strip()
    if not q:
        return "NO QUESTION RECEIVED."
    try:
        raw = _query_llm(q)
        answer = sanitize(raw)
        if not answer:
            answer = "NO ANSWER."
        print(f"[termi3] Q: {q!r} -> A: {answer!r}")  # App Lab console log
        return answer
    except Exception as exc:  # noqa: BLE001
        print(f"[termi3] error: {exc}")
        return "SORRY, MY BRAIN IS OFFLINE."


def _llm_health_ok() -> bool:
    """True if llama-server answers /health with 200."""
    try:
        with _conn_lock:
            conn = _get_conn()
            conn.request("GET", "/health")
            resp = conn.getresponse()
            resp.read()
            return resp.status == 200
    except (http.client.HTTPException, OSError):
        _reset_conn()
        return False


def _warm_cache() -> None:
    """Prime the llama-server prefix cache with our exact system prompt so the
    first REAL question gets the fast (~2s) path instead of a cold (~10s) one.
    """
    try:
        _query_llm("ping")
        print("[termi3] prefix cache warmed")
    except Exception as exc:  # noqa: BLE001
        print(f"[termi3] warm-up skipped: {exc}")


_warmed = False


def is_ready() -> bool:
    """MCU polls this at startup to know Python is up.

    On a cold boot the llama-server (separate systemd service) may still be
    loading its model. To make the FIRST question reliable, we don't report
    ready until the LLM answers /health, then we warm the cache once.
    The MCU keeps polling, so returning False just makes it wait.
    """
    global _warmed
    if _warmed:
        return True
    if not _llm_health_ok():
        return False          # LLM not up yet; MCU will poll again
    _warmed = True
    _warm_cache()
    return True


# Register functions the MCU can call.
Bridge.provide("ask", ask)          # noqa: F405
Bridge.provide("is_ready", is_ready)  # noqa: F405


def loop():
    # Nothing to do in the main loop; all work is driven by MCU calls.
    import time
    time.sleep(1)


App.run(user_loop=loop)  # noqa: F405

sketch.ino

Arduino
Main sketch for STM32 side
/*
 *  """
 *  Termi3 - Python Script
 *  Cameron Coward
 *  cameroncoward.com
 *  9/24/2026
 *  """
 *
 *   1. Print "ASK TERMI3: "                (bit-bang TX)
 *   2. Read keystrokes until CR            (bit-bang RX -> line buffer)
 *   3. Bridge.call("ask", question)        (blocks for the answer)
 *   4. Print "TERMI3: " + answer           (bit-bang TX)
 *   5. Loop
 *
 * serial config (Phase 3): A0=TX, A1=RX, 300 baud (BIT_US=3333),
 * 7 data bits, odd parity, 1 stop, TX_INVERT=false / RX idle HIGH.
 * Terminal switches: HIGH SPEED, HALF DUPLEX, ON LINE.
 */

#include <Arduino_RouterBridge.h>
#include <Arduino_LED_Matrix.h>

// ---------------- Status LED matrix (UNO Q, 8x13) ----------------
// Three status icons show where the loop is, set once at each boundary
// (never refreshed in loop()). The firmware refresh IRQ keeps the frame lit.
// NOTE: that refresh IRQ runs during bit-bang serial. If it ever jitters the
// 3.3ms/bit timing (corrupt chars), matrix.end() disables it as a fallback.
Arduino_LED_Matrix matrix;

// 8 rows x 13 cols, row-major, 1 = LED on. setGrayscaleBits(1) -> on/off.

// "?"  waiting for a question
const uint8_t ICON_WAIT[104] = {
  0,0,0,0,1,1,1,1,1,0,0,0,0,
  0,0,0,1,1,0,0,0,1,1,0,0,0,
  0,0,0,0,0,0,0,0,1,1,0,0,0,
  0,0,0,0,0,0,0,1,1,0,0,0,0,
  0,0,0,0,0,0,1,1,0,0,0,0,0,
  0,0,0,0,0,0,1,1,0,0,0,0,0,
  0,0,0,0,0,0,0,0,0,0,0,0,0,
  0,0,0,0,0,0,1,1,0,0,0,0,0
};

// hourglass  processing (waiting on the LLM answer)
const uint8_t ICON_PROC[104] = {
  0,0,0,1,1,1,1,1,1,1,0,0,0,
  0,0,0,0,1,1,1,1,1,0,0,0,0,
  0,0,0,0,0,1,1,1,0,0,0,0,0,
  0,0,0,0,0,0,1,0,0,0,0,0,0,
  0,0,0,0,0,0,1,0,0,0,0,0,0,
  0,0,0,0,0,1,1,1,0,0,0,0,0,
  0,0,0,0,1,1,1,1,1,0,0,0,0,
  0,0,0,1,1,1,1,1,1,1,0,0,0
};

// speaker  responding (printing the answer)
const uint8_t ICON_RESP[104] = {
  0,0,0,0,0,1,0,0,0,0,0,0,0,
  0,0,0,0,1,1,0,0,1,0,0,0,0,
  0,0,1,1,1,1,0,1,0,1,0,0,0,
  0,0,1,1,1,1,0,1,0,1,0,1,0,
  0,0,1,1,1,1,0,1,0,1,0,1,0,
  0,0,1,1,1,1,0,1,0,1,0,0,0,
  0,0,0,0,1,1,0,0,1,0,0,0,0,
  0,0,0,0,0,1,0,0,0,0,0,0,0
};

// A0/A1 (STM32U585 PA4/PA5, aliased D14/D15) are full digital GPIO  the
// bit-bang is pure polled digitalRead/Write, so no pin-specific peripheral is
// needed. NOTE: A0/A1 are NOT 5V tolerant; the MAX3232 runs at 3.3V so R1OUT
// swings 0-3.3V. Keep the 5V connect-detect line (DA-15 pin 11) away from them.
const int PIN_TX = A0;
const int PIN_RX = A1;
const int BIT_US = 3333;
const int HALF_BIT_US = 1666;
const bool TX_INVERT = false;
const bool RX_IDLE_HIGH = true;

const int  MAX_Q = 200;         // max question length

// ---------------- TX (confirmed) ----------------
inline void txBit(bool level) {
  digitalWrite(PIN_TX, TX_INVERT ? !level : level);
}
void sendChar(char c) {
  uint8_t data = (uint8_t)c & 0x7F;
  txBit(false); delayMicroseconds(BIT_US);           // start
  uint8_t ones = 0;
  for (int i = 0; i < 7; i++) {
    bool b = (data >> i) & 0x01; if (b) ones++;
    txBit(b); delayMicroseconds(BIT_US);
  }
  txBit(ones % 2 == 0); delayMicroseconds(BIT_US);   // odd parity
  txBit(true); delayMicroseconds(BIT_US);            // stop
}
void sendString(const char *s) { while (*s) sendChar(*s++); }
void sendString(const String &s) {
  for (unsigned i = 0; i < s.length(); i++) sendChar(s[i]);
}
void newline() { sendChar('\r'); sendChar('\n'); }
// Advance the paper n lines (CR+LF each  bare CR would not feed the paper).
void feedLines(int n) { while (n-- > 0) newline(); }

// ---------------- RX (confirmed) ----------------
inline bool rxLevel() {
  bool high = (digitalRead(PIN_RX) == HIGH);
  return RX_IDLE_HIGH ? high : !high;
}
// Blocking read of one char. Returns -1 on framing glitch.
int receiveChar() {
  while (rxLevel() == true) { /* idle */ }
  delayMicroseconds(HALF_BIT_US);
  if (rxLevel() != false) return -1;                 // false start
  delayMicroseconds(BIT_US);
  uint8_t data = 0;
  for (int i = 0; i < 7; i++) {
    if (rxLevel()) data |= (1 << i);
    delayMicroseconds(BIT_US);
  }
  delayMicroseconds(BIT_US);   // past parity
  return data & 0x7F;
}

// Read a full line until CR (0x0D) or LF, into buf. Returns length.
// Uppercases letters (terminal is uppercase anyway; keeps things clean).
int readLine(char *buf, int maxlen) {
  int n = 0;
  for (;;) {
    int c = receiveChar();
    if (c < 0) continue;                 // ignore framing glitches
    if (c == '\r' || c == '\n') break;   // end of question
    if (c == 8 || c == 127) {            // backspace/delete
      if (n > 0) n--;
      continue;
    }
    if (n < maxlen - 1 && c >= 32) {     // printable
      buf[n++] = (char)c;
    }
  }
  buf[n] = '\0';
  return n;
}

// ---------------- App flow ----------------
char question[MAX_Q];

void printPrompt() {
  newline();
  sendString("ASK TERMI3: ");
}

void setup() {
  pinMode(PIN_TX, OUTPUT);
  txBit(true);                 // idle mark
  pinMode(PIN_RX, INPUT);

  matrix.begin();              // starts firmware refresh IRQ
  matrix.setGrayscaleBits(1);  // 1 bit -> on/off
  matrix.draw(ICON_PROC);      // "processing" while warming up

  Bridge.begin();              // ~2s, blocking
  Monitor.begin(115200);       // debug to App Lab console

  // Wait until the Python side AND the local LLM are ready. is_ready() only
  // returns true once llama-server answers /health, so on a cold boot this
  // loop waits out the model-load time. Wait indefinitely (conference: the
  // board is powered on and must self-heal, never give up).
  bool ready = false;
  unsigned long lastDot = 0;
  while (!ready) {
    Bridge.call("is_ready").result(ready);
    if (!ready) {
      // Print a slow row of dots so the paper shows it's warming up, not dead.
      unsigned long now = millis();
      if (now - lastDot > 3000) { sendChar('.'); lastDot = now; }
      delay(200);
    }
  }

  delay(300);
  newline();
  // Startup banner (branded prompt, per on-paper interaction design).
  sendString("TERMI3 - A TYPEWRITER THAT THINKS");
  newline();

  matrix.draw(ICON_WAIT);      // ready, waiting for a question
}

void loop() {
  matrix.draw(ICON_WAIT);                // waiting for a question
  printPrompt();

  int len = readLine(question, MAX_Q);
  if (len == 0) return;                  // empty line -> re-prompt

  matrix.draw(ICON_PROC);                // CR received -> processing

  // Roll the paper up so the user can read back what they just typed.
  feedLines(4);

  // Static "thinking" marker. Printed BEFORE the blocking Bridge call, so it
  // appears the moment the question is accepted. This is an ACKNOWLEDGEMENT,
  // not a progress bar: at the conference many users couldn't tell whether
  // their question had been received. A timed/animated indicator isn't
  // possible here anyway  Bridge.call().result() blocks, so loop() gets no
  // chance to print while waiting (see project memory: no concurrency, to
  // protect bit-bang timing).
  sendString("...");

  // Ask Python (which queries the local LLM). Blocks for the answer.
  // NOTE: using the plain .result() form (confirmed by docs). Timeout is
  // handled Python-side (urllib 30s). If the default Bridge timeout proves
  // too short for a cold first answer, we'll add MCU-side timeout config.
  String answer;
  bool ok = Bridge.call("ask", String(question)).result(answer);

  feedLines(2);                          // separate the marker from the answer

  matrix.draw(ICON_RESP);                // answer in hand -> responding
  sendString("TERMI3: ");
  if (ok && answer.length() > 0) {
    sendString(answer);
  } else {
    sendString("SORRY, NO ANSWER.");
  }

  feedLines(4);                          // leave the answer visible above the platen
}

Credits

Cameron Coward
22 projects • 1739 followers
Writer for Hackster News. Proud husband and dog dad. Maker and serial hobbyist. Check out my YouTube channel: Serial Hobbyism

Comments