diff --git a/.bazelignore b/.bazelignore
new file mode 100644
index 0000000..5a8b3ef
--- /dev/null
+++ b/.bazelignore
@@ -0,0 +1 @@
+execroot
diff --git a/.gitignore b/.gitignore
index 66817fe..7c80236 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,8 @@ tmp/
 /**/config*.toml
 /**/config*.yml
 MODULE.bazel.lock
+*.tar.zst
+*.zapp
+.BUILDINFO
+.MTREE
+.PKGINFO
diff --git a/projects/cabal/src/cabal/__main__.py b/projects/cabal/src/cabal/__main__.py
new file mode 100644
index 0000000..316dd6d
--- /dev/null
+++ b/projects/cabal/src/cabal/__main__.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+
+import sys
+import logging
+import socket
+import socketserver
+from pathlib import Path
+from subprocess import check_output
+import shlex
+import os
+
+logging.basicConfig(level=logging.INFO)
+
+CONFIG = None
+
+
+class RequestHandler(socketserver.BaseRequestHandler):
+    def setup(self) -> None:
+        logging.info("Start request.")
+
+    def handle(self) -> None:
+        conn = self.request
+        while True:
+            data = conn.recv(1024).decode("utf-8")
+
+            if not data:
+                break
+
+            data = shlex.split(data)
+
+            logging.info(f"recv: {data!r}")
+            if command_allowed(data):
+                resp = check_output()
+                conn.sendall(resp)
+
+            else:
+                logging.error("Invalid request %r", data)
+
+    def finish(self) -> None:
+        logging.info("Finish request.")
+
+
+class Server(socketserver.ThreadingUnixStreamServer):
+    def server_activate(self) -> None:
+        logging.info("Server started on %s", self.server_address)
+        super().server_activate()
+
+
+if __name__ == "__main__":
+    socket_path = Path(sys.argv[1])
+
+    if socket_path.exists():
+        socket_path.unlink()
+
+    with Server(str(socket_path), RequestHandler) as server:
+        server.serve_forever()
diff --git a/projects/lcdbackpack/BUILD.bazel b/projects/lcdbackpack/BUILD.bazel
new file mode 100644
index 0000000..810e416
--- /dev/null
+++ b/projects/lcdbackpack/BUILD.bazel
@@ -0,0 +1,7 @@
+py_project(
+  name = "lcdbackpack",
+  lib_deps = [
+    py_requirement("click"),
+    py_requirement("pyserial"),
+  ],
+)
diff --git a/projects/lcdbackpack/src/lcdbackpack/__init__.py b/projects/lcdbackpack/src/lcdbackpack/__init__.py
new file mode 100644
index 0000000..fe4eee6
--- /dev/null
+++ b/projects/lcdbackpack/src/lcdbackpack/__init__.py
@@ -0,0 +1,347 @@
+#!/usr/bin/env python3
+
+import os
+import serial
+import sys
+from time import sleep
+from typing import Optional
+
+import click
+
+
+class LcdBackpack:
+    """
+    The LcdBackpack class exposes the commands available on the Adafruit LCD USB/Serial Backpack via simple methods.
+    """
+
+    # Quoting from the official firmware implementation -
+    CHANGESPLASH = 0x40        # COL * ROW chars!
+    DISPLAY_ON =  0x42         # backlight. 1 argument afterwards, in minutes
+    AUTOWRAPLINE_ON = 0x43
+    AUTOWRAPLINE_OFF = 0x44
+    DISPLAY_OFF =  0x46
+    SETCURSOR_POSITION = 0x47  # 2 args: col, row
+    SETCURSOR_HOME = 0x48
+    UNDERLINECURSOR_ON = 0x4A
+    UNDERLINECURSOR_OFF = 0x4B
+    MOVECURSOR_BACK = 0x4C
+    MOVECURSOR_FORWARD = 0x4D
+    CUSTOM_CHARACTER = 0x4E    # 9 args: char #, 8 bytes data
+    SET_CONTRAST = 0x50        # 1 arg
+    AUTOSCROLL_ON = 0x51
+    AUTOSCROLL_OFF = 0x52
+    BLOCKCURSOR_ON = 0x53
+    BLOCKCURSOR_OFF = 0x54
+    GPO_OFF = 0x56
+    GPO_ON = 0x57
+    CLEAR = 0x58
+    SETSAVE_CONTRAST = 0x91    # 1 arg
+    SET_BRIGHTNESS = 0x99      # 1 arg: scale
+    SETSAVE_BRIGHTNESS = 0x98  # 1 arg: scale
+    LOADCUSTOMCHARBANK = 0xC0  # 9 args: char #, 8 bytes data
+    SAVECUSTOMCHARBANK = 0xC1  # 9 args: char #, 8 bytes data
+    GPO_START_ONOFF = 0xC3
+    RGBBACKLIGHT = 0xD0        # 3 args - R G B
+    SETSIZE = 0xD1             # 2 args - Cols & Rows
+    TESTBAUD = 0xD2            # zero args, prints baud rate to uart
+    STARTL_COMMAND = 0xFE      # magic byte indicating a subsequent command list
+
+    def __init__(self, serial_device, baud_rate=115200, rows=16, cols=2):
+        self._baud_rate = baud_rate
+        self._serial_device = os.path.realpath(serial_device)
+        self._ser: Optional[serial.Serial] = None
+        self._rows = rows
+        self._cols = cols
+
+    def __enter__(self) -> "LcdBackpack":
+        """
+        Connects to the serial port.
+        """
+        self.connect()
+        return self
+
+    def __exit__(self, type, value, traceback):
+        if self.connected:
+            self.disconnect()
+
+    def connect(self):
+        """
+        Manually open a connection.
+        """
+        self._ser = serial.Serial(self._serial_device, self._baud_rate, timeout=1)
+
+    def disconnect(self):
+        """
+        Closes the serial port connection.
+        """
+        if self._ser is not None and self._ser.is_open:
+            self._ser.close()
+            self._ser = None
+
+    @property
+    def connected(self):
+        """
+        Returns the state of the serial connection.
+        :return: True if the serial port is open (connected), False otherwise.
+        """
+        return self._ser is not None and self._ser.is_open
+
+    def _write_command(self, command_list):
+        """
+        Writes the given command list to the LCD back pack.
+        :param command_list: The commands to be written to the LCD back pack.
+        """
+        assert self.connected, "Connection required"
+        self._ser.write(bytes([LcdBackpack.STARTL_COMMAND] + command_list))
+
+    def display_on(self):
+        """
+        Switches the LCD backlight on.
+        """
+        self._write_command([LcdBackpack.DISPLAY_ON, 0])
+
+    def display_off(self):
+        """
+        Switches the LCD backlight off.
+        """
+        self._write_command([LcdBackpack.DISPLAY_OFF])
+
+    def set_brightness(self, brightness: int):
+        """
+        Sets the brightness of the LCD backlight.
+        :param brightness: integer value from 0 - 255
+        """
+        assert 0 <= brightness <= 255
+        self._write_command([LcdBackpack.SET_BRIGHTNESS, brightness])
+
+    def set_contrast(self, contrast: int):
+        """
+        Sets the contrast of the LCD character text.
+        :param contrast: integer value from 0 - 255
+        """
+        assert 0 <= contrast <= 255
+        self._write_command([LcdBackpack.SET_CONTRAST, contrast])
+
+    def set_autoscroll(self, auto_scroll: bool):
+        """
+        Sets the autoscrolling capability to the value provided.
+        :param auto_scroll: true/false
+        """
+        if auto_scroll:
+            self._write_command([LcdBackpack.AUTOSCROLL_ON])
+        else:
+            self._write_command([LcdBackpack.AUTOSCROLL_OFF])
+
+    def set_autowrapline(self, auto_wrap: bool):
+        """
+        Sets the autowrapping capability to the value provided.
+        :param auto_wrap: true/false
+        """
+        if auto_wrap:
+            self._write_command([LcdBackpack.AUTOWRAPLINE_ON])
+        else:
+            self._write_command([LcdBackpack.AUTOWRAPLINE_OFF])
+
+    def set_cursor_position(self, column: int, row: int):
+        """
+        Moves the cursor to the provided position.
+        :param column: integer value for column posititon starting from 1
+        :param row: integer value for row position starting from 1
+        """
+        assert 1 <= column <= 255
+        assert 1 <= row <= 255
+        self._write_command([LcdBackpack.SETCURSOR_POSITION, column, row])
+
+    def set_cursor_home(self):
+        """
+        Moves the cursor to the "home" positon: column = 1, row = 1.
+        """
+        self._write_command([LcdBackpack.SETCURSOR_HOME])
+
+    def cursor_forward(self):
+        """
+        Moves the cursor forward one character.
+        """
+        self._write_command([LcdBackpack.MOVECURSOR_FORWARD])
+
+    def cursor_back(self):
+        """
+        Moves the cursor backward one character.
+        """
+        self._write_command([LcdBackpack.MOVECURSOR_BACK])
+
+    def set_underline_cursor(self, underline_cursor: bool):
+        """
+        Enables/disables the underline cursor.
+        :param underline_cursor: true/false
+        """
+        if underline_cursor:
+            self._write_command([LcdBackpack.UNDERLINECURSOR_ON])
+        else:
+            self._write_command([LcdBackpack.UNDERLINECURSOR_OFF])
+
+    def set_block_cursor(self, block_cursor: bool):
+        """
+        Enables/disables the block cursor.
+        :param block_cursor:
+        """
+        if block_cursor:
+            self._write_command([LcdBackpack.BLOCKCURSOR_OFF])
+        else:
+            self._write_command([LcdBackpack.BLOCKCURSOR_ON])
+
+    def set_backlight_rgb(self, red: int, green: int, blue: int):
+        """
+        Sets the RGB LCD backlight to the colour provided by red, green and blue values.
+        :param red: integer value 0 - 255
+        :param green: integer value 0 - 255
+        :param blue: integer value 0 - 255
+        """
+        assert 0 <= red <= 255
+        assert 0 <= green <= 255
+        assert 0 <= blue <= 255
+        self._write_command([LcdBackpack.RGBBACKLIGHT, red, green, blue])
+
+    def set_backlight_red(self):
+        """
+        Sets the backlight of an RGB LCD display to be red.
+        """
+        self.set_backlight_rgb(0xFF, 0, 0)
+
+    def set_backlight_green(self):
+        """
+        Sets the backlight of an RGB LCD display to be green.
+        """
+        self.set_backlight_rgb(0, 0xFF, 0)
+
+    def set_backlight_blue(self):
+        """
+        Sets the backlight of an RGB LCD display to be blue.
+        """
+        self.set_backlight_rgb(0, 0, 0xFF)
+
+    def set_backlight_white(self):
+        """
+        Sets the backlight of an RGB LCD display to be white.
+        """
+        self.set_backlight_rgb(0xFF, 0xFF, 0xFF)
+
+    def set_lcd_size(self, columns: int, rows: int):
+        """
+        Sets the size of the LCD display (columns, rows).
+        :param columns: the number of columns
+        :param rows: the number of rows.
+        """
+        assert 0 <= columns <= 255
+        assert 0 <= rows <= 255
+        self._write_command([LcdBackpack.SETSIZE, columns, rows])
+
+    def set_gpio_high(self, gpio: int):
+        """
+        Sets the given GPIO pin on the LCD back pack HIGH (5V).
+        :param gpio: the GPIO pin to set HIGH (1 - 4)
+        """
+        assert 1 <= gpio <= 4
+        self._write_command([LcdBackpack.GPO_ON, gpio])
+
+    def set_gpio_low(self, gpio: int):
+        """
+        Sets the given GPIO pin on the LCD back pack LOW (5V).
+        :param gpio: the GPIO pin to set LOW (1 - 4)
+        """
+        assert 1 <= gpio <= 4
+        self._write_command([LcdBackpack.GPO_OFF, gpio])
+
+    def clear(self):
+        """
+        Clears all the characters from the display.
+        """
+        self._write_command([LcdBackpack.CLEAR])
+
+    def write(self, string):
+        """
+        Writes the given text on the LCD display.
+        :param string: the text to be written on the display.
+        """
+        if self._ser is None or self._ser.closed:
+            raise serial.SerialException('Not connected')
+
+        self._ser.write(str.encode(string))
+
+    def set_splash_screen(self, string):
+        """
+        Sets the LCD splash screen.
+        :param string: the text to be displayed at LCD start up
+        :param lcd_chars: the total characters of the LCD display
+        """
+        self._write_command([LcdBackpack.CHANGESPLASH] + list(str.encode(string).ljust(self._cols*self._rows)))
+
+
+@click.group()
+@click.option("-d", "--device", "device", default="/dev/ttyACM0")
+@click.option("-b", "--baud", "baud", type=int, default=115200)
+@click.pass_context
+def cli(ctx, device, baud):
+    ctx.obj = LcdBackpack(device, baud)
+
+    if ctx.invoked_subcommand is None:
+        ctx.invoke(do_write)
+
+
+@cli.command("on")
+@click.pass_context
+def do_on(ctx):
+    with ctx.obj as dev:
+        dev.display_on()
+
+
+@cli.command("off")
+@click.pass_context
+def do_off(ctx):
+    with ctx.obj as dev:
+        dev.display_off()
+
+
+@cli.command("backlight")
+@click.argument("brightness", type=int)
+@click.pass_context
+def do_backlight(ctx, brightness):
+    with ctx.obj as dev:
+        dev.set_brightness(brightness)
+
+
+@cli.command("contrast")
+@click.argument("contrast", type=int)
+@click.pass_context
+def do_contrast(ctx, contrast):
+    with ctx.obj as dev:
+        dev.set_contrast(contrast)
+
+
+@cli.command("clear")
+@click.pass_context
+def do_clear(ctx):
+    with ctx.obj as dev:
+        dev.clear()
+
+
+@cli.command("write")
+@click.option("-d", "--delay", "delay", type=int, default=100, help="Number of ms to delay between sending lines")
+@click.pass_context
+def do_write(ctx, delay):
+    with ctx.obj as dev:
+        buff = [""] * dev._cols
+        for line in sys.stdin:
+            line = line[:dev._rows].strip()
+            buff = buff[1:] + [line]
+
+            dev.clear()
+            for l in buff:
+                dev.write(l)
+                dev.write("\r")
+
+            sleep(delay/1e3)
+
+
+if __name__ == "__main__":
+    cli()
diff --git a/projects/tickertape/BUILD.bazel b/projects/tickertape/BUILD.bazel
new file mode 100644
index 0000000..3e494a3
--- /dev/null
+++ b/projects/tickertape/BUILD.bazel
@@ -0,0 +1,9 @@
+py_project(
+  name = "tickertape",
+  shebang = "/usr/bin/env python3",
+  main = "src/tickertape/__main__.py",
+  main_deps = [
+    "//projects/lcdbackpack",
+    py_requirement("click"),
+  ],
+)
diff --git a/projects/tickertape/Dockerfile b/projects/tickertape/Dockerfile
new file mode 100644
index 0000000..a393ad5
--- /dev/null
+++ b/projects/tickertape/Dockerfile
@@ -0,0 +1,5 @@
+FROM docker.io/library/python:3.13
+
+COPY tickertape.zapp /usr/bin/tickertape
+
+ENTRYPOINT ["/usr/bin/tickertape"]
diff --git a/projects/tickertape/PKGBUILD b/projects/tickertape/PKGBUILD
new file mode 100644
index 0000000..3a6e9aa
--- /dev/null
+++ b/projects/tickertape/PKGBUILD
@@ -0,0 +1,55 @@
+pkgname=("tickertape")
+pkgver="0.1.0"
+pkgrel=1
+epoch=1
+pkgdesc="A LCD ticker for host details"
+
+arch=("any")
+
+depends=(
+    python
+)
+
+source=(
+    tickertape.service
+    config.toml
+)
+
+sha256sums=(
+    "SKIP"
+    "SKIP"
+)
+
+makedepends=(
+    python
+    bazel
+)
+
+# Input file verification
+verify() {
+    true
+}
+
+# Input file patching
+prepare() {
+    true
+}
+
+# Building
+build() {
+    cd ..
+    bazel build :tickertape.zapp
+    cp -f $(bazel run --run_under=echo :tickertape.zapp) $srcdir/
+}
+
+# Post-build verification (tests)
+check() {
+    true
+}
+
+# Emplacing artifacts
+package() {
+    install -Dm755 tickertape.zapp "${pkgdir}"/usr/bin/tickertape
+    install -Dm644 tickertape.service "${pkgdir}"/usr/lib/systemd/system/tickertape.service
+    install -Dm644 config.toml "${pkgdir}"/etc/tickertape/config.toml
+}
diff --git a/projects/tickertape/mkdocker.sh b/projects/tickertape/mkdocker.sh
new file mode 100644
index 0000000..78b1840
--- /dev/null
+++ b/projects/tickertape/mkdocker.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+set -eoux -o pipefail
+
+cd "$(dirname "$(realpath "$0")")"
+tmpdir=$(mktemp -d)
+target="//projects/tickertape:tickertape.zapp"
+
+bazel build "${target}"
+zapp=$(realpath $(bazel run --run_under=echo "${target}"))
+
+cp Dockerfile "$tmpdir/"
+cp -r "$zapp" "$tmpdir/"
+
+cd "${tmpdir}"
+docker build "$@" -f Dockerfile -t registry.tirefireind.us/arrdem/tickertape:latest .
+docker push registry.tirefireind.us/arrdem/tickertape:latest
diff --git a/projects/tickertape/pkg/tickertape/usr/bin/tickertape b/projects/tickertape/pkg/tickertape/usr/bin/tickertape
new file mode 100755
index 0000000..93669a0
Binary files /dev/null and b/projects/tickertape/pkg/tickertape/usr/bin/tickertape differ
diff --git a/projects/tickertape/pkg/tickertape/usr/lib/systemd/system/tickertape.service b/projects/tickertape/pkg/tickertape/usr/lib/systemd/system/tickertape.service
new file mode 100644
index 0000000..9449181
--- /dev/null
+++ b/projects/tickertape/pkg/tickertape/usr/lib/systemd/system/tickertape.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=A tickertape for Adafruit LCD displays
+
+[Service]
+ExecStart=/usr/bin/tickertape --config /etc/tickertape/config.toml
+
+[Install]
+WantedBy=multi-user.target
diff --git a/projects/tickertape/src/tickertape.service b/projects/tickertape/src/tickertape.service
new file mode 120000
index 0000000..6e9e760
--- /dev/null
+++ b/projects/tickertape/src/tickertape.service
@@ -0,0 +1 @@
+/home/arrdem/Documents/hobby/programming/source/projects/tickertape/tickertape.service
\ No newline at end of file
diff --git a/projects/tickertape/src/tickertape/__main__.py b/projects/tickertape/src/tickertape/__main__.py
new file mode 100644
index 0000000..df398a2
--- /dev/null
+++ b/projects/tickertape/src/tickertape/__main__.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+
+from datetime import datetime
+import ipaddress
+from itertools import islice
+import json
+import os
+from pathlib import Path
+import re
+from shutil import which
+import signal
+import socket
+from subprocess import check_call, check_output
+from time import sleep
+import tomllib
+from typing import Iterable
+
+import click
+from lcdbackpack import LcdBackpack
+
+
+SPLASH_TEXT = "%-16s\r%-16s" % ("TireFire", "    Industries")
+DEFAULT_CONFIG = {
+    "tickertape": {
+        "splash_text": SPLASH_TEXT,
+        "clock_rate": 3,
+        "character_rate": 0.75,
+    },
+    "lcd": {
+        "path": "/dev/serial/by-id/usb-239a_Adafruit_Industries-if00",
+        "height": 2,
+        "width": 16,
+        "brightness": 16,
+    },
+    "proc": {"path": "/proc"},
+    "dev": {"path": "/dev"},
+}
+
+
+def hostname() -> str:
+    hn = os.getenv("TICKERTAPE_HOSTNAME") or socket.gethostname()
+
+    def _handler(m):
+        if m.group(2) and len(m.group(2)) > 3:
+            return f"{m.group(1)}..."
+        else:
+            return m.group(0)
+
+    return re.sub("(.{1,13})(.*)", _handler, hn)
+
+
+def defaultaddr(
+    subnets: Iterable[ipaddress.IPv4Network] = (ipaddress.IPv4Network("10.0.0.0/8"),)
+) -> str:
+    # Get all the IPv4 addresses for this host
+    addresses = [
+        ipaddress.IPv4Address(it)
+        for it in socket.gethostbyname_ex(socket.gethostname())[-1]
+    ]
+    # Search out
+    addresses = [
+        it
+        for it in addresses
+        if any(net.network_address <= it <= net.broadcast_address for net in subnets)
+    ]
+
+    for ip in addresses:
+        yield f"Addr: {ip}"
+
+
+def render_duration(uptime_seconds):
+    uptime_seconds = int(uptime_seconds)
+
+    sec = uptime_seconds % 60
+
+    minutes = (uptime_seconds // 60) % 60
+    minutes = f"{minutes}m" if minutes > 0 else ""
+
+    hours = uptime_seconds // 60 // 60 % 24
+    hours = f"{hours}h" if hours > 0 else ""
+
+    days = uptime_seconds // 60 // 60 // 24
+    days = f"{days}d" if days > 0 else ""
+
+    return f"{days}{hours}{minutes}{sec}s"
+
+
+def uptime(proc: Path):
+    with open(proc / "uptime", "r") as f:
+        uptime_seconds = int(float(f.readline().split()[0]))
+
+    yield f"Up: {render_duration(uptime_seconds)}"
+
+
+def load():
+    load1, load5, load15 = os.getloadavg()
+    yield f"Load: 1m {load1:.2f}"
+    yield f"Load: 5m {load5:.2f}"
+    yield f"Load: 15m {load15:.2f}"
+
+
+def df(
+    dirs: Iterable[Path] = (
+        Path("/"),
+        Path("/boot"),
+        Path("/srv"),
+    )
+):
+    for dir in dirs:
+        if dir.is_dir():
+            dev, blocks, used, avail, pct, mtpt = re.split(
+                r"\s+", check_output(["df", str(dir)]).decode("utf-8").splitlines()[1]
+            )
+            yield f"{dir} usage: {pct}"
+
+
+def zfs():
+    if zpool := which("zpool"):
+        status = json.loads(check_output([zpool, "status", "--json"]))
+
+        for pool_name, pool_stats in status["pools"].items():
+            yield f"Zpool {pool_name}:"
+            yield f"  State: {pool_stats['state']}"
+            yield f"  Errors: {pool_stats['error_count']}"
+            yield f"  Used: {pool_stats['vdevs'][pool_name]['alloc_space']}"
+            yield f"  Total: {pool_stats['vdevs'][pool_name]['total_space']}"
+            last_scan_date = datetime.strptime(
+                pool_stats["scan_stats"]["end_time"], "%a %b %d %H:%M:%S %p %Z %Y"
+            )
+            delta = datetime.now(tz=last_scan_date.tzinfo) - last_scan_date
+            yield f"  Scanned: {render_duration(delta.total_seconds())} ago"
+
+
+def window(text: str, n=2):
+    """
+    Returns a sliding window (of width n) over data from the iterable.
+
+    s -> (s0,s1,...s[n-1]), (s1,s2,...,sn), ...
+    """
+    offset = 0
+    while offset + n <= len(text):
+        yield text[offset : offset + n]
+        offset += 1
+    else:
+        if len(text) < n:
+            yield text
+
+
+def recursive_merge(dict1, dict2):
+    for key, value in dict2.items():
+        if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict):
+            dict1[key] = recursive_merge(dict1[key], value)
+        else:
+            dict1[key] = value
+    return dict1
+
+
+@click.option(
+    "--config",
+    "config",
+    type=Path,
+    help="A configuration file",
+    default=Path("/etc/tickertape/config.toml"),
+)
+@click.command()
+def main(config: Path):
+
+    # Config loading
+    config_data = {}
+
+    if config.exists():
+        with open(config, "rb") as fp:
+            config_data: Config = tomllib.load(fp)
+
+    config_data = recursive_merge(config_data, DEFAULT_CONFIG)
+
+    dev = Path(os.getenv("TICKERTAPE_DEVDIR") or config_data["dev"]["path"])
+    proc = Path(os.getenv("TICKERTAPE_PROCDIR") or config_data["proc"]["path"])
+
+    hn = hostname()
+
+    # Functions to iterators over lines (or scrolls )
+    lower = [
+        defaultaddr,
+        lambda: uptime(proc),
+        load,
+        df,
+        zfs,
+    ]
+
+    # seconds between display refreshes
+    clock_rate = config_data["tickertape"]["clock_rate"]
+    character_rate = config_data["tickertape"]["character_rate"]
+
+    lcd_width = config_data["lcd"]["width"]
+    lcd_height = config_data["lcd"]["height"]
+
+    with LcdBackpack(config_data["lcd"]["path"]) as dev:
+        # Screen setup
+        dev.set_splash_screen(config_data["tickertape"]["splash_text"])
+        dev.set_lcd_size(
+            lcd_width,
+            lcd_height,
+        )
+        dev.set_brightness(config_data["lcd"]["brightness"])
+        dev.set_autoscroll(False)
+
+        # Display the splashscreen
+        dev.clear()
+        dev.set_cursor_position(1, 1)
+        dev.write(SPLASH_TEXT)
+        sleep(clock_rate)
+
+        # Set the hostname row
+        dev.set_cursor_position(1, 1)
+        dev.clear()
+        dev.write(hn)
+
+        def sigterm_handler(_signo, _stack_frame):
+            dev.clear()
+            exit(0)
+
+        signal.signal(signal.SIGTERM, sigterm_handler)
+        signal.signal(signal.SIGINT, sigterm_handler)
+
+        while True:
+            for h in lower:
+                # FIXME: Support dependency injection or args to the handlers?
+                for line in h():
+                    # Lines could be longer than 16 characters and if they are
+                    # we want to shift characters down the line. This is an
+                    # inefficient but idiomatic way to achieve that.
+                    for view in window(line, n=lcd_width):
+                        # Reset the cursor
+                        dev.set_cursor_position(1, 2)
+                        # Lay down exactly 16 characters, space padded to flush any state
+                        dev.write("%-16s" % view)
+                        # Pause between sequential characters
+                        sleep(character_rate)
+
+                    else:
+                        # And then wait the clock interval
+                        sleep(clock_rate - character_rate)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/projects/tickertape/tickertape.service b/projects/tickertape/tickertape.service
new file mode 100644
index 0000000..9449181
--- /dev/null
+++ b/projects/tickertape/tickertape.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=A tickertape for Adafruit LCD displays
+
+[Service]
+ExecStart=/usr/bin/tickertape --config /etc/tickertape/config.toml
+
+[Install]
+WantedBy=multi-user.target
diff --git a/tools/python/pythonshim b/tools/python/pythonshim
index 20da612..ec86148 100755
--- a/tools/python/pythonshim
+++ b/tools/python/pythonshim
@@ -4,7 +4,7 @@
 # But ... that's exactly what we want to do.
 # So this script exists to find a 'compliant' Python install and use that.
 
-PYTHONREV="3.12"
+PYTHONREV="3"
 CMD="python${PYTHONREV}"
 
 if [ -x "$(command -v "$CMD")" ]; then
diff --git a/tools/python/requirements.in b/tools/python/requirements.in
index 4f04805..688d810 100644
--- a/tools/python/requirements.in
+++ b/tools/python/requirements.in
@@ -56,3 +56,4 @@ yaspin
 pytimeparse
 git+https://github.com/arrdem/jaraco.text.git@0dd8d0b25a93c3fad896f3a92d11caff61ff273d#egg=jaraco.text
 onepasswordconnectsdk
+pyserial
diff --git a/tools/python/requirements.lock b/tools/python/requirements.lock
deleted file mode 100644
index 5071da1..0000000
--- a/tools/python/requirements.lock
+++ /dev/null
@@ -1,147 +0,0 @@
-aiohttp==3.9.5
-aiohttp-basicauth==1.0.0
-aiosignal==1.3.1
-aiosql==10.1
-alabaster==0.7.16
-anyio==4.3.0
-async-lru==2.0.4
-attrs==23.2.0
-autocommand==2.2.2
-autoflake==2.3.1
-Babel==2.15.0
-beautifulsoup4==4.12.3
-black==24.4.2
-blinker==1.8.2
-build==1.2.1
-cachetools==5.3.3
-certifi==2024.2.2
-charset-normalizer==3.3.2
-cheroot==10.0.1
-CherryPy==18.8.0
-click==8.1.7
-colored==2.2.4
-commonmark==0.9.1
-coverage==7.5.1
-decorator==5.1.1
-deepmerge==1.1.1
-docutils==0.21.2
-ExifRead==3.0.0
-flake8==7.0.0
-Flask==3.0.3
-frozenlist==1.4.1
-h11==0.14.0
-httpcore==0.16.3
-httpx==0.23.3
-hypothesis==6.102.1
-ibis==3.3.0
-icmplib==3.0.4
-idna==3.7
-imagesize==1.4.1
-inflect==7.2.1
-iniconfig==2.0.0
-isort==5.13.2
-itsdangerous==2.2.0
-jaraco.collections==5.0.1
-jaraco.context==5.3.0
-jaraco.functools==4.0.1
-jaraco.text @ git+https://github.com/arrdem/jaraco.text.git@0dd8d0b25a93c3fad896f3a92d11caff61ff273d
-jedi==0.19.1
-Jinja2==3.1.4
-jsonschema==4.22.0
-jsonschema-path==0.3.2
-jsonschema-specifications==2023.12.1
-lark==1.1.9
-lazy-object-proxy==1.10.0
-libsass==0.23.0
-livereload==2.6.3
-lxml==5.2.2
-Markdown==3.6
-MarkupSafe==2.1.5
-mccabe==0.7.0
-meraki==1.46.0
-mirakuru==2.5.2
-mistune==3.0.2
-more-itertools==10.2.0
-multidict==6.0.5
-mypy-extensions==1.0.0
-octorest==0.4
-onepasswordconnectsdk==1.5.1
-openapi-schema-validator==0.6.2
-openapi-spec-validator==0.7.1
-packaging==24.0
-parso==0.8.4
-pathable==0.4.3
-pathspec==0.12.1
-picobox==4.0.0
-pip-tools==7.4.1
-platformdirs==4.2.1
-pluggy==1.5.0
-port-for==0.7.2
-portend==3.2.0
-prompt-toolkit==3.0.43
-proquint==0.2.1
-psutil==5.9.8
-psycopg==3.1.19
-psycopg2==2.9.9
-pudb==2024.1
-py==1.11.0
-pycodestyle==2.11.1
-pycryptodome==3.20.0
-pyflakes==3.2.0
-Pygments==2.18.0
-pyproject_hooks==1.1.0
-pyrsistent==0.20.0
-pytest==8.2.0
-pytest-cov==5.0.0
-pytest-postgresql==6.0.0
-pytest-pudb==0.7.0
-pytest-timeout==2.3.1
-python-dateutil==2.9.0.post0
-pytimeparse==1.1.8
-pytz==2024.1
-PyYAML==6.0.1
-recommonmark==0.7.1
-redis==5.0.4
-referencing==0.31.1
-requests==2.31.0
-retry==0.9.2
-rfc3339-validator==0.1.4
-rfc3986==1.5.0
-rpds-py==0.18.1
-setuptools==69.5.1
-six==1.16.0
-smbus2==0.4.3
-sniffio==1.3.1
-snowballstemmer==2.2.0
-sortedcontainers==2.4.0
-soupsieve==2.5
-Sphinx==7.3.7
-sphinx_mdinclude==0.6.0
-sphinxcontrib-applehelp==1.0.8
-sphinxcontrib-devhelp==1.0.6
-sphinxcontrib-htmlhelp==2.0.5
-sphinxcontrib-httpdomain==1.8.1
-sphinxcontrib-jsmath==1.0.1
-sphinxcontrib-openapi==0.8.4
-sphinxcontrib-programoutput==0.17
-sphinxcontrib-qthelp==1.0.7
-sphinxcontrib-serializinghtml==1.1.10
-tempora==5.5.1
-termcolor==2.3.0
-toml==0.10.2
-tornado==6.4
-typeguard==4.2.1
-typing_extensions==4.11.0
-unify==0.5
-untokenize==0.1.1
-urllib3==2.2.1
-urwid==2.6.11
-urwid_readline==0.14
-wcwidth==0.2.13
-websocket-client==1.8.0
-Werkzeug==3.0.3
-wheel==0.43.0
-yamllint==1.35.1
-yarl==1.9.4
-yaspin==3.0.2
-zc.lockfile==3.0.post1
diff --git a/tools/python/requirements_lock.txt b/tools/python/requirements_lock.txt
index 3e0862b..4e6884f 100644
--- a/tools/python/requirements_lock.txt
+++ b/tools/python/requirements_lock.txt
@@ -1,144 +1,149 @@
-aiohttp==3.9.3
-aiohttp-basicauth==1.0.0
-aiosignal==1.3.1
-aiosql==9.4
-alabaster==0.7.13
-annotated-types==0.5.0
+aiohappyeyeballs==2.4.4
+aiohttp==3.11.11
+aiohttp_basicauth==1.1.0
+aiosignal==1.3.2
+aiosql==13.0
+alabaster==1.0.0
+anyio==4.7.0
 async-lru==2.0.4
-async-timeout==4.0.2
-attrs==23.2.0
+attrs==24.3.0
 autocommand==2.2.2
-autoflake==2.2.1
-Babel==2.12.1
+autoflake==2.3.1
+babel==2.16.0
 beautifulsoup4==4.12.3
-black==24.1.1
-blinker==1.6.2
-build==0.10.0
-cachetools==5.3.2
-certifi==2023.7.22
-charset-normalizer==3.2.0
-cheroot==10.0.0
+black==24.10.0
+blinker==1.9.0
+build==1.2.2.post1
+cachetools==5.5.0
+certifi==2024.12.14
+charset-normalizer==3.4.1
+cheroot==10.0.1
 CherryPy==18.8.0
-click==8.1.7
+click==8.1.8
 colored==2.2.4
 commonmark==0.9.1
-coverage==7.2.7
+coverage==7.6.9
 decorator==5.1.1
-deepmerge==1.1.0
-docutils==0.20.1
+deepmerge==2.0
+docutils==0.21.2
 ExifRead==3.0.0
-flake8==7.0.0
-Flask==3.0.2
-frozenlist==1.4.0
-hypothesis==6.98.2
+flake8==7.1.1
+Flask==3.1.0
+frozenlist==1.5.0
+h11==0.14.0
+httpcore==0.16.3
+httpx==0.23.3
+hypothesis==6.123.1
 ibis==3.3.0
 icmplib==3.0.4
-idna==3.4
+idna==3.10
 imagesize==1.4.1
-inflect==7.0.0
+inflect==7.4.0
 iniconfig==2.0.0
 isort==5.13.2
-itsdangerous==2.1.2
-jaraco.collections==4.3.0
-jaraco.context==4.3.0
-jaraco.functools==3.8.0
+itsdangerous==2.2.0
+jaraco.collections==5.1.0
+jaraco.context==6.0.1
+jaraco.functools==4.1.0
 jaraco.text @ git+https://github.com/arrdem/jaraco.text.git@0dd8d0b25a93c3fad896f3a92d11caff61ff273d
-jedi==0.18.2
-Jinja2==3.1.3
-jsonschema==4.18.4
-jsonschema-path==0.3.2
-jsonschema-spec==0.2.3
-jsonschema-specifications==2023.7.1
-lark==1.1.9
-lazy-object-proxy==1.9.0
+jedi==0.19.2
+Jinja2==3.1.5
+jsonschema==4.23.0
+jsonschema-path==0.3.3
+jsonschema-specifications==2023.12.1
+lark==1.2.2
+lazy-object-proxy==1.10.0
 libsass==0.23.0
-livereload==2.6.3
-lxml==5.1.0
-Markdown==3.5.2
-MarkupSafe==2.1.3
+livereload==2.7.1
+lxml==5.3.0
+Markdown==3.7
+MarkupSafe==3.0.2
 mccabe==0.7.0
-meraki==1.42.0
-mirakuru==2.5.1
-mistune==2.0.5
-more-itertools==10.0.0
-multidict==6.0.4
+meraki==1.53.0
+mirakuru==2.5.3
+mistune==3.0.2
+more-itertools==10.5.0
+multidict==6.1.0
 mypy-extensions==1.0.0
 octorest==0.4
-openapi-schema-validator==0.6.0
+onepasswordconnectsdk==1.5.1
+openapi-schema-validator==0.6.2
 openapi-spec-validator==0.7.1
-packaging==23.1
-parso==0.8.3
+packaging==24.2
+parso==0.8.4
 pathable==0.4.3
-pathspec==0.11.1
-picobox==3.0.0
-pip==23.1.2
-pip-tools==7.3.0
-platformdirs==3.9.1
-pluggy==1.2.0
-port-for==0.7.1
+pathspec==0.12.1
+picobox==4.0.0
+pip-tools==7.4.1
+platformdirs==4.3.6
+pluggy==1.5.0
+port-for==0.7.4
 portend==3.2.0
-prompt-toolkit==3.0.43
+prompt_toolkit==3.0.48
+propcache==0.2.1
 proquint==0.2.1
-psutil==5.9.5
-psycopg==3.1.9
-psycopg2==2.9.9
-pudb==2022.1.3
+psutil==6.1.1
+psycopg==3.2.3
+psycopg2==2.9.10
+pudb==2024.1.3
 py==1.11.0
-pycodestyle==2.11.1
-pycryptodome==3.20.0
-pydantic==2.1.1
-pydantic_core==2.4.0
+pycodestyle==2.12.1
+pycryptodome==3.21.0
 pyflakes==3.2.0
-Pygments==2.15.1
-pyproject_hooks==1.0.0
+Pygments==2.18.0
+pyproject_hooks==1.2.0
 pyrsistent==0.20.0
-pytest==7.4.0
-pytest-cov==4.1.0
-pytest-postgresql==5.1.0
+pyserial==3.5
+pytest==8.3.4
+pytest-cov==6.0.0
+pytest-postgresql==6.1.1
 pytest-pudb==0.7.0
-pytest-timeout==2.2.0
+pytest-timeout==2.3.1
+python-dateutil==2.9.0.post0
 pytimeparse==1.1.8
-pytz==2023.3
-PyYAML==6.0.1
+PyYAML==6.0.2
 recommonmark==0.7.1
-redis==5.0.1
-referencing==0.29.3
-requests==2.31.0
+redis==5.2.1
+referencing==0.35.1
+requests==2.32.3
 retry==0.9.2
 rfc3339-validator==0.1.4
-rpds-py==0.9.2
-setuptools==68.0.0
-six==1.16.0
-smbus2==0.4.3
+rfc3986==1.5.0
+rpds-py==0.22.3
+setuptools==75.6.0
+six==1.17.0
+smbus2==0.5.0
+sniffio==1.3.1
 snowballstemmer==2.2.0
 sortedcontainers==2.4.0
-soupsieve==2.4.1
-Sphinx==7.2.6
-sphinx_mdinclude==0.5.3
-sphinxcontrib-applehelp==1.0.4
-sphinxcontrib-devhelp==1.0.2
-sphinxcontrib-htmlhelp==2.0.1
+soupsieve==2.6
+Sphinx==8.1.3
+sphinx_mdinclude==0.6.2
+sphinxcontrib-applehelp==2.0.0
+sphinxcontrib-devhelp==2.0.0
+sphinxcontrib-htmlhelp==2.1.0
 sphinxcontrib-httpdomain==1.8.1
 sphinxcontrib-jsmath==1.0.1
-sphinxcontrib-openapi==0.8.3
-sphinxcontrib-programoutput==0.17
-sphinxcontrib-qthelp==1.0.3
-sphinxcontrib-serializinghtml==1.1.10
-tempora==5.5.0
+sphinxcontrib-openapi==0.8.4
+sphinxcontrib-programoutput==0.18
+sphinxcontrib-qthelp==2.0.0
+sphinxcontrib-serializinghtml==2.0.0
+tempora==5.7.0
 termcolor==2.3.0
 toml==0.10.2
-tornado==6.3.2
-typing_extensions==4.7.1
+tornado==6.4.2
+typeguard==4.4.1
+typing_extensions==4.12.2
 unify==0.5
 untokenize==0.1.1
-urllib3==2.0.4
-urwid==2.1.2
-urwid-readline==0.13
-wcwidth==0.2.6
-websocket-client==1.6.1
-Werkzeug==3.0.1
-wheel==0.40.0
-yamllint==1.34.0
-yarl==1.9.2
-yaspin==3.0.1
+urllib3==2.3.0
+urwid==2.6.16
+urwid_readline==0.15.1
+wcwidth==0.2.13
+websocket-client==1.8.0
+Werkzeug==3.1.3
+wheel==0.45.1
+yamllint==1.35.1
+yarl==1.18.3
+yaspin==3.1.0
 zc.lockfile==3.0.post1