FRT Ticker
Live Internet routing statistics including IPv4/IPv6 prefixes, FRT size, RPKI status, AS-path length, top origin ASes, and IPv6 adoption.
How to run
This app is a single Python file that talks directly to the BUSY Bar HTTP API: no libraries, no setup. Connect your bar via USB and run:
python app.py
Over USB the bar is always at 10.0.4.20. On Wi-Fi, pass the bar's IP
with --host <address>.
No bar at hand? Run the
BUSY Bar Emulator
and point the app at it: python app.py --host 127.0.0.1:8080.
To keep it running without a terminal open, install it with busybar-manager, which handles the virtualenv, autostart and scheduling, and lets you switch between apps from a web dashboard.
Source code
app.py
#!/usr/bin/env python3
"""FRT Ticker — Internet routing-table telemetry for BUSY Bar.
Lightweight live mode using APNIC/Thyme routing summaries.
Statistics are read from APNIC's daily BGP Routing Table Analysis (Thyme):
* IPv4/IPv6 announced prefixes
* combined FRT size
* RPKI Valid / Unknown / Invalid counts
* average AS-path length (including prepends)
* top IPv4 and IPv6 origin ASes by number of announced prefixes
* worldwide IPv6 adoption (APNIC Labs IPv6 Capable, 30-day average)
Examples:
python3 app.py --host 127.0.0.1:8080 # live (default)
python3 app.py --host 127.0.0.1:8080 --demo
python3 app.py --host 127.0.0.1:8080 --live --speed 6
python3 app.py --host 127.0.0.1:8080 --live --autorefresh 60
--speed is the minimum persistence time, in seconds, of each screen.
Top Origin screens stay visible for twice the normal screen dwell time.
--autorefresh is the live-data refresh interval in minutes (default: 60; 0 disables).
Rendering deliberately uses BUSY Bar native text elements for strings/numbers.
Only decorative pixel graphics are rasterized into a 72x16 background image.
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import math
import random
import re
import struct
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib
from pathlib import Path
APP = "frt-ticker"
W, H = 72, 16
DEFAULT_HOST = "10.0.4.20"
CACHE_NAME = ".frt-ticker-cache.json"
APNIC_BASE = "https://thyme.apnic.net/current"
APNIC_V4_SUMMARY = APNIC_BASE + "/data-summary"
APNIC_V6_SUMMARY = APNIC_BASE + "/ipv6-summary"
APNIC_TOP_ORIGIN_V4 = APNIC_BASE + "/data-ASnet"
APNIC_TOP_ORIGIN_V6 = APNIC_BASE + "/ipv6-asn-table"
APNIC_IPV6_ADOPTION = "https://data1.labs.apnic.net/v6stats/v6region/XA.json"
# Decorative palette (native text colors are specified independently below).
BLACK = (0, 0, 0)
DIM = (42, 55, 70)
BLUE = (0, 145, 255)
CYAN = (0, 230, 255)
VIOLET = (135, 72, 255)
MAGENTA = (240, 48, 180)
GREEN = (36, 220, 105)
AMBER = (255, 174, 0)
RED = (255, 50, 66)
DARK_BLUE = (4, 24, 45)
DARK_GREEN = (2, 33, 20)
DARK_AMBER = (43, 27, 0)
DARK_RED = (42, 4, 8)
# BUSY native-text colors (#RRGGBBAA).
C_WHITE = "#EEF6FFFF"
C_DIM = "#63758AFF"
C_BLUE = "#0091FFFF"
C_CYAN = "#00E6FFFF"
C_VIOLET = "#8750FFFF"
C_GREEN = "#24DC69FF"
C_AMBER = "#FFAE00FF"
C_RED = "#FF3242FF"
@dataclasses.dataclass
class Metrics:
ipv4: int = 0
ipv6: int = 0
rpki_valid: int | None = None
rpki_unknown: int | None = None
rpki_invalid: int | None = None
path_avg_prepend: float | None = None
top_asn: int | None = None
top_name: str = ""
top_prefixes: int | None = None
top6_asn: int | None = None
top6_name: str = ""
top6_prefixes: int | None = None
ipv6_adoption: float | None = None
collector: str = "APNIC DIX-IE"
data_time: str = ""
source: str = "APNIC THYME"
@property
def total(self) -> int:
return self.ipv4 + self.ipv6
DEMO = Metrics(
ipv4=1_074_549,
ipv6=244_318,
rpki_valid=942_576,
rpki_unknown=374_745,
rpki_invalid=1_546,
path_avg_prepend=4.74,
top_asn=16509,
top_name="AMAZON-02",
top_prefixes=15_642,
top6_asn=9808,
top6_name="CHINAMOBILE-CN",
top6_prefixes=7_178,
ipv6_adoption=43.36,
collector="APNIC DIX-IE",
data_time="DEMO",
source="DEMO DATA",
)
# ---------------------------------------------------------------------------
# Tiny decorative raster engine — NO textual content is rendered here.
# ---------------------------------------------------------------------------
def blank():
return [BLACK] * (W * H)
def px(buf, x, y, c):
if 0 <= x < W and 0 <= y < H:
buf[y * W + x] = c
def rect(buf, x, y, w, h, c):
x0, x1 = max(0, x), min(W, x + w)
y0, y1 = max(0, y), min(H, y + h)
for yy in range(y0, y1):
off = yy * W
for xx in range(x0, x1):
buf[off + xx] = c
def line(buf, x0, y0, x1, y1, c):
dx = abs(x1 - x0)
sx = 1 if x0 < x1 else -1
dy = -abs(y1 - y0)
sy = 1 if y0 < y1 else -1
err = dx + dy
while True:
px(buf, x0, y0, c)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def glow_dot(buf, x, y, c):
px(buf, x, y, c)
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
if 0 <= x + dx < W and 0 <= y + dy < H:
px(buf, x + dx, y + dy, tuple(v // 3 for v in c))
def png(pixels) -> bytes:
raw = bytearray()
for y in range(H):
raw.append(0)
base = y * W
for x in range(W):
r, g, b = pixels[base + x]
raw += bytes((r, g, b, 255))
def chunk(tag, data):
body = tag + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(bytes(raw), 6))
+ chunk(b"IEND", b""))
# ---------------------------------------------------------------------------
# Native BUSY text helpers
# ---------------------------------------------------------------------------
def native_text(value, x, y, *, font="small", color=C_WHITE, align="top_left",
width=None, scroll_rate=None, scroll_start_delay=None,
scroll_repeat_delay=None):
d = {
"id": "native_text",
"type": "text",
"text": str(value),
"x": x,
"y": y,
"font": font,
"color": color,
"align": align,
}
if width is not None:
d["width"] = width
if scroll_rate is not None:
d["scroll_rate"] = scroll_rate
if scroll_start_delay is not None:
d["scroll_start_delay"] = scroll_start_delay
if scroll_repeat_delay is not None:
d["scroll_repeat_delay"] = scroll_repeat_delay
return d
# ---------------------------------------------------------------------------
# BUSY Bar HTTP transport
# ---------------------------------------------------------------------------
class Busy:
def __init__(self, host: str):
host = host.replace("http://", "").replace("https://", "").rstrip("/")
self.base = "http://" + host
self.frame = 0
self.ring = 4
self.text_slots = 6
self._last_text_slots = [None] * self.text_slots
def _req(self, method, path, body=None, content_type=None, timeout=7):
headers = {}
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(self.base + path, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.getcode(), r.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def show(self, pixels, texts):
"""Draw one decorative image plus native BUSY text elements.
The same fixed text IDs are emitted every frame, including empty slots,
so stale text from the previous card cannot remain on screen.
"""
fn = f"frame{self.frame % self.ring}.png"
self.frame += 1
qs = urllib.parse.urlencode({"application_name": APP, "file": fn})
status, _ = self._req("POST", "/api/assets/upload?" + qs, png(pixels), "application/octet-stream")
if status == 508:
return
if status not in (200, 201, 204):
raise RuntimeError(f"asset upload HTTP {status}")
elements = [{"id": "bg", "type": "image", "path": fn, "x": 0, "y": 0}]
texts = list(texts[:self.text_slots])
while len(texts) < self.text_slots:
texts.append(native_text("", 0, 0, color="#00000000"))
# Native text elements persist on the BUSY Bar until replaced. Only
# resend a slot when its contents/properties actually change. Besides
# reducing traffic, this is essential for scrolling text: repeatedly
# drawing the same text element every animation frame restarts the
# firmware's scroll animation before it has a chance to move.
for i, item in enumerate(texts):
sig = json.dumps(item, sort_keys=True, separators=(",", ":"))
if sig == self._last_text_slots[i]:
continue
self._last_text_slots[i] = sig
el = dict(item)
el["id"] = f"txt{i}"
elements.append(el)
body = json.dumps({
"application_name": APP,
"priority": 30,
"elements": elements,
}).encode()
status, _ = self._req("POST", "/api/display/draw", body, "application/json")
if status not in (200, 201, 204, 409):
raise RuntimeError(f"draw HTTP {status}")
def clear(self):
qs = urllib.parse.urlencode({"application_name": APP})
try:
self._req("DELETE", "/api/display/draw?" + qs)
except Exception:
pass
# ---------------------------------------------------------------------------
# Visual scenes
# ---------------------------------------------------------------------------
class Visualizer:
def __init__(self):
rng = random.Random(42)
self.nodes = [(rng.randrange(W), rng.randrange(H), rng.choice([-1, 1])) for _ in range(10)]
self.history4 = [0.42, .48, .46, .55, .61, .58, .66, .63, .70, .73, .71, .77, .79]
self.history6 = [.28, .31, .35, .34, .41, .46, .48, .53, .51, .59, .63, .68, .72]
def _bg_grid(self, b, phase, color=DARK_BLUE):
for x in range(-phase % 8, W, 8):
for y in (2, 8, 14):
px(b, x, y, color)
for y in range(H):
px(b, int((y * 7 + phase * 2) % W), y, color)
def number_card(self, t, label, value, color, text_color, history, icon):
b = blank()
self._bg_grid(b, int(t * 5), tuple(v // 4 for v in color))
rect(b, 0, 0, 12, 16, tuple(max(0, v // 7) for v in color))
# Pixel icon only; textual label/value are native BUSY elements.
if icon == "4":
rect(b, 3, 4, 2, 6, color); rect(b, 7, 3, 2, 10, color); rect(b, 3, 9, 6, 2, color)
else:
rect(b, 3, 4, 6, 2, color); rect(b, 3, 4, 2, 8, color); rect(b, 3, 8, 6, 2, color); rect(b, 7, 8, 2, 5, color); rect(b, 3, 11, 6, 2, color)
texts = [
native_text(label, 15, -1, font="small", color=C_DIM),
native_text(f"{value:,}", 15, 6, font="normal", color=text_color),
]
return b, texts
def total(self, t, m):
b = blank()
cx, cy = 9, 8
for yy in range(2, 15):
for xx in range(2, 17):
dx, dy = xx - cx, yy - cy
d2 = dx * dx + (dy * 1.18) ** 2
if 31 <= d2 <= 43:
px(b, xx, yy, CYAN)
for yy in (5, 8, 11):
line(b, 3, yy, 15, yy, DARK_BLUE)
line(b, 9, 2, 9, 14, DARK_BLUE)
glow_dot(b, 9 + int(5 * math.sin(t * 1.8)), 8, CYAN)
for k in range(4):
x = 20 + ((int(t * 11) + k * 15) % 52)
px(b, x, 15, [BLUE, CYAN, VIOLET, MAGENTA][k])
texts = [
native_text("FRT SIZE", 20, -1, font="small", color=C_DIM),
native_text(f"{m.total:,}", 20, 6, font="normal", color=C_WHITE),
]
return b, texts
def ipv6_adoption(self, t, m):
b = blank()
# Adoption is shown as a radial "signal" motif rather than another
# globe/FRT counter. Concentric arcs pulse outwards from a small IPv6
# node, suggesting reach/coverage across the Internet.
cx, cy = 8, 8
pulse = int(t * 5) % 12
for r, col in ((2, CYAN), (4, VIOLET), (6, DARK_BLUE)):
rr = r + (1 if pulse in (r, r + 1) else 0)
for a in range(-60, 61, 10):
rad = math.radians(a)
x = cx + int(round(math.cos(rad) * rr))
y = cy + int(round(math.sin(rad) * rr * 0.70))
px(b, x, y, col)
glow_dot(b, cx - 2, cy, CYAN)
value = m.ipv6_adoption
if value is not None:
# A compact vertical gauge on the far right keeps this screen
# visually distinct from the horizontal FRT-size animation.
filled = max(0, min(12, int(round(12 * value / 100.0))))
for i in range(12):
y = 14 - i
px(b, 70, y, VIOLET if i < filled else DARK_BLUE)
px(b, 71, y, VIOLET if i < filled else DARK_BLUE)
value_text = f"{value:.1f}%"
else:
value_text = "N/A"
texts = [
native_text("IPV6 ADOPTION", 18, -1, font="small", color=C_VIOLET),
native_text(value_text, 18, 6, font="normal", color=C_WHITE),
]
return b, texts
def rpki(self, t, m, focus):
vals = [m.rpki_valid, m.rpki_unknown, m.rpki_invalid]
cols = [GREEN, AMBER, RED]
ctexts = [C_GREEN, C_AMBER, C_RED]
darks = [DARK_GREEN, DARK_AMBER, DARK_RED]
labels = ["RPKI VALID", "RPKI UNKNOWN", "RPKI INVALID"]
value = vals[focus]
b = blank()
# Shield motif kept far right/top so it never collides with the percentage.
for yy in range(0, 7):
span = max(0, 4 - abs(3 - yy))
if span:
px(b, 65 - span, yy, darks[focus])
px(b, 65 + span, yy, darks[focus])
glow_dot(b, 65, 3, cols[focus])
if all(v is not None for v in vals) and sum(vals) > 0:
total = sum(vals)
x = 0
for v, c in zip(vals, cols):
w = int(round(W * v / total))
rect(b, x, 15, w, 1, c)
x += w
else:
for x in range(W):
if (x + int(t * 8)) % 7 < 3:
px(b, x, 15, DIM)
texts = [native_text(labels[focus], 1, -1, font="small", color=ctexts[focus])]
if value is None or m.total <= 0:
texts.append(native_text("N/A", 1, 7, font="normal", color=C_DIM))
else:
pct = 100.0 * value / m.total
# Separate, native-font fields. Percentage gets its own right-aligned
# space rather than sharing a hand-drawn 3x5 row with the count.
texts.extend([
native_text(f"{value:,}", 1, 7, font="small", color=C_WHITE),
native_text(f"{pct:.1f}%", 72, 7, font="small", color=ctexts[focus], align="top_right"),
])
return b, texts
def path(self, t, m):
b = blank()
xs = [46, 51, 56, 61, 66, 70]
node_cols = [CYAN, BLUE, BLUE, BLUE, VIOLET, MAGENTA]
for i in range(len(xs) - 1):
line(b, xs[i], 10, xs[i + 1], 10, DIM)
pulse = int(t * 5) % len(xs)
for i, x in enumerate(xs):
c = (255, 255, 255) if i == pulse else node_cols[i]
rect(b, x - 1, 8, 3, 5, tuple(v // 3 for v in c))
px(b, x, 10, c)
value = "N/A" if m.path_avg_prepend is None else f"{m.path_avg_prepend:.2f}"
texts = [
native_text("AS PATH AVG", 1, -1, font="small", color=C_VIOLET),
native_text(value, 1, 7, font="normal", color=C_WHITE),
native_text("HOPS", 26, 8, font="tiny", color=C_DIM),
]
return b, texts
def top_origin(self, t, m, version=4):
b = blank()
# crown pixel-art only
rect(b, 1, 2, 9, 2, DARK_AMBER)
for x, y in ((1, 1), (5, 0), (9, 1)):
px(b, x, y, AMBER)
if version == 6:
asn_v, name_v, pfx_v = m.top6_asn, m.top6_name, m.top6_prefixes
title = "TOP ORIGIN V6"
accent = C_VIOLET
else:
asn_v, name_v, pfx_v = m.top_asn, m.top_name, m.top_prefixes
title = "TOP ORIGIN V4"
accent = C_AMBER
asn = "AS?" if asn_v is None else f"AS{asn_v}"
pfx = "?" if pfx_v is None else f"{pfx_v:,}"
# Two-line layout: title + one high-contrast native text ticker.
# BUSY Bar scrolls the second line only when it exceeds the available width.
name = name_v or "UNKNOWN"
if name.upper().startswith("AS-"):
named_as = name
else:
named_as = f"AS-{name}"
scroll = f"{pfx} pfx - {named_as} - {asn}"
texts = [
native_text(title, 13, -1, font="small", color=accent),
native_text(scroll, 0, 8, font="small", color=C_WHITE, width=72,
scroll_rate=360, scroll_start_delay=500, scroll_repeat_delay=900),
]
return b, texts
SCENES = [
"ipv4",
"ipv6",
"total",
"rpki_valid",
"rpki_unknown",
"rpki_invalid",
"path_prepend",
"top_origin_v4",
"top_origin_v6",
"ipv6_adoption",
]
def _approx_text_width(text: str, font="small") -> int:
"""Conservative BUSY bitmap-font width estimate used only for dwell timing."""
# The native small font is roughly 4 px/character on this display.
return len(text) * (4 if font == "small" else 3)
def _top_origin_scroll_text(m: Metrics, version: int) -> str:
if version == 6:
asn_v, name_v, pfx_v = m.top6_asn, m.top6_name, m.top6_prefixes
else:
asn_v, name_v, pfx_v = m.top_asn, m.top_name, m.top_prefixes
asn = "AS?" if asn_v is None else f"AS{asn_v}"
pfx = "?" if pfx_v is None else f"{pfx_v:,}"
name = name_v or "UNKNOWN"
if name.upper().startswith("AS-"):
named_as = name
else:
named_as = f"AS-{name}"
return f"{pfx} pfx - {named_as} - {asn}"
def scene_duration(name: str, m: Metrics, speed: float) -> float:
"""Return screen dwell time; Top Origin cards remain for 2x --speed."""
base = max(0.5, speed)
if name in ("top_origin_v4", "top_origin_v6"):
return base * 2.0
return base
def render_scene_index(v: Visualizer, m: Metrics, index: int, t: float):
name = SCENES[index % len(SCENES)]
if name == "ipv4":
return (*v.number_card(t, "IPV4 PREFIXES", m.ipv4, BLUE, C_BLUE, v.history4, "4"), name)
if name == "ipv6":
return (*v.number_card(t, "IPV6 PREFIXES", m.ipv6, VIOLET, C_VIOLET, v.history6, "6"), name)
if name == "ipv6_adoption":
return (*v.ipv6_adoption(t, m), name)
if name == "total":
return (*v.total(t, m), name)
if name == "rpki_valid":
return (*v.rpki(t, m, 0), name)
if name == "rpki_unknown":
return (*v.rpki(t, m, 1), name)
if name == "rpki_invalid":
return (*v.rpki(t, m, 2), name)
if name == "path_prepend":
return (*v.path(t, m), name)
if name == "top_origin_v4":
return (*v.top_origin(t, m, 4), name)
return (*v.top_origin(t, m, 6), name)
# ---------------------------------------------------------------------------
# Lightweight APNIC/Thyme data source
# ---------------------------------------------------------------------------
def fetch_text(url: str, *, max_bytes=256_000, timeout=12, range_bytes=None, debug=False, label=None):
headers = {"User-Agent": "busybar-frt-ticker/0.2"}
if range_bytes:
headers["Range"] = f"bytes=0-{range_bytes - 1}"
tag = label or url
if debug:
extra = f" range=0-{range_bytes - 1}" if range_bytes else ""
print(f"[debug] download {tag}: GET {url}{extra}", flush=True)
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read(max_bytes)
if debug:
status = getattr(r, "status", None) or r.getcode()
print(f"[debug] download {tag}: HTTP {status}, received {len(raw)} bytes", flush=True)
return raw.decode("utf-8", "replace")
except Exception as e:
if debug:
print(f"[debug] download {tag}: ERROR {type(e).__name__}: {e}", flush=True)
raise
def _extract_int(text: str, label: str) -> int:
m = re.search(r"^\s*" + re.escape(label) + r"\s*:\s*([0-9,]+)\s*$", text, re.MULTILINE | re.IGNORECASE)
if not m:
raise RuntimeError(f"APNIC summary field not found: {label}")
return int(m.group(1).replace(",", ""))
def _extract_float(text: str, pattern: str) -> float:
m = re.search(pattern + r"\s*:\s*([0-9]+(?:\.[0-9]+)?)", text, re.MULTILINE | re.IGNORECASE)
if not m:
raise RuntimeError(f"APNIC summary field not found: {pattern}")
return float(m.group(1))
def parse_v4_summary(text: str):
return {
"prefixes": _extract_int(text, "BGP routing table entries examined"),
"valid": _extract_int(text, "Number of IPv4 prefixes with a valid ROA"),
"invalid": _extract_int(text, "Number of IPv4 prefixes with an invalid ROA"),
"unknown": _extract_int(text, "Number of IPv4 prefixes with no ROA"),
"path_avg": _extract_float(text, r"Average AS path length visible in the Internet Routing Table"),
}
def parse_v6_summary(text: str):
return {
"prefixes": _extract_int(text, "BGP routing table entries examined"),
"valid": _extract_int(text, "Number of IPv6 prefixes with a valid ROA"),
"invalid": _extract_int(text, "Number of IPv6 prefixes with an invalid ROA"),
"unknown": _extract_int(text, "Number of IPv6 prefixes with no ROA"),
"path_avg": _extract_float(text, r"Average AS path length"),
}
def parse_top_origin_v4(text: str):
"""Parse the first data row of APNIC's global IPv4 per-AS prefix league."""
for raw in text.splitlines():
# ASN, No of nets, /20 equiv, MaxAgg, Description
m = re.match(r"^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s*$", raw)
if not m:
continue
asn = int(m.group(1))
prefixes = int(m.group(2))
desc = re.sub(r"\s+", " ", m.group(5)).strip()
if asn > 0 and prefixes > 0:
return asn, prefixes, desc
return None, None, ""
def parse_top_origin_v6(text: str):
"""Parse the first data row of APNIC's global IPv6 per-AS prefix league."""
for raw in text.splitlines():
m = re.match(r"^\s*(\d+)\s+(\d+)\s+(.+?)\s*$", raw)
if not m:
continue
asn = int(m.group(1))
prefixes = int(m.group(2))
desc = re.sub(r"\s+", " ", m.group(3)).strip()
if asn > 0 and prefixes > 0:
return asn, prefixes, desc
return None, None, ""
def parse_ipv6_adoption(text: str, debug=False) -> float:
"""Return the latest APNIC Labs worldwide IPv6 Capable value.
APNIC publishes the World (XA) measurement as JSON. Each dated record
contains smoothed intervals; use the 30-day ``capable_pc`` value, which
approximates a monthly worldwide IPv6-capability percentage.
"""
try:
payload = json.loads(text)
except (json.JSONDecodeError, TypeError) as exc:
if debug:
print(f"[debug] ipv6-adoption: invalid JSON: {exc}", flush=True)
print(f"[debug] ipv6-adoption: response excerpt={text[:1000]!r}", flush=True)
raise RuntimeError("APNIC IPv6 adoption response is not valid JSON") from exc
rows = payload.get("data", []) if isinstance(payload, dict) else []
candidates = []
for row in rows:
if not isinstance(row, dict):
continue
date = row.get("date", "")
interval = row.get("30", {})
if not isinstance(interval, dict):
continue
value = interval.get("capable_pc")
try:
value = float(value)
except (TypeError, ValueError):
continue
if date and 0.0 <= value <= 100.0:
candidates.append((date, value))
if debug:
print(f"[debug] ipv6-adoption: parsed {len(candidates)} JSON samples", flush=True)
if not candidates:
raise RuntimeError("APNIC worldwide IPv6 Capable rate not found in JSON")
date, value = max(candidates, key=lambda item: item[0])
if debug:
print(f"[debug] ipv6-adoption: newest sample {date} -> {value:.2f}%", flush=True)
return value
def fetch_live_metrics(progress=None, debug=False):
if progress:
progress("IPV4", 0)
v4 = parse_v4_summary(fetch_text(APNIC_V4_SUMMARY, debug=debug, label="ipv4-summary"))
if progress:
progress("IPV6", 0)
v6 = parse_v6_summary(fetch_text(APNIC_V6_SUMMARY, debug=debug, label="ipv6-summary"))
if progress:
progress("IPV6 ADOPT.", 0)
adoption_text = fetch_text(APNIC_IPV6_ADOPTION, max_bytes=6_000_000, timeout=20, debug=debug, label="ipv6-adoption")
adoption = parse_ipv6_adoption(adoption_text, debug=debug)
# This endpoint is several MB in full, but it is sorted. Read only its first
# 64 KiB; that contains the heading and the top rows. A Range header is sent
# when supported, and read() is capped even if the server ignores Range.
if progress:
progress("TOP AS", 0)
top_chunk = fetch_text(APNIC_TOP_ORIGIN_V4, max_bytes=65_536, range_bytes=65_536, debug=debug, label="top-origin-v4")
top_asn, top_pfx, top_name = parse_top_origin_v4(top_chunk)
top6_chunk = fetch_text(APNIC_TOP_ORIGIN_V6, max_bytes=16_384, range_bytes=16_384, debug=debug, label="top-origin-v6")
top6_asn, top6_pfx, top6_name = parse_top_origin_v6(top6_chunk)
total = v4["prefixes"] + v6["prefixes"]
path_avg = None
if total:
path_avg = ((v4["path_avg"] * v4["prefixes"]) +
(v6["path_avg"] * v6["prefixes"])) / total
return Metrics(
ipv4=v4["prefixes"],
ipv6=v6["prefixes"],
rpki_valid=v4["valid"] + v6["valid"],
rpki_unknown=v4["unknown"] + v6["unknown"],
rpki_invalid=v4["invalid"] + v6["invalid"],
path_avg_prepend=path_avg,
top_asn=top_asn,
top_name=top_name,
top_prefixes=top_pfx,
top6_asn=top6_asn,
top6_name=top6_name,
top6_prefixes=top6_pfx,
ipv6_adoption=adoption,
collector="APNIC DIX-IE",
data_time=time.strftime("%Y-%m-%d"),
source="APNIC THYME",
)
# ---------------------------------------------------------------------------
# Cache / background loader
# ---------------------------------------------------------------------------
def cache_path():
return Path(__file__).resolve().parent / CACHE_NAME
def save_cache(m: Metrics):
try:
cache_path().write_text(json.dumps(dataclasses.asdict(m), indent=2))
except OSError:
pass
def load_cache(max_age_minutes=90):
p = cache_path()
try:
if not p.exists() or time.time() - p.stat().st_mtime > max_age_minutes * 60:
return None
data = json.loads(p.read_text())
# Ignore caches from the old MRT-based prototype.
if "path_avg_unique" in data:
data.pop("path_avg_unique", None)
data.pop("peer_ip", None)
data.pop("peer_asn", None)
return Metrics(**{k: v for k, v in data.items() if k in Metrics.__dataclass_fields__})
except Exception:
return None
class LiveLoader:
"""Fetch fresh live data in the background; cache is fallback only."""
def __init__(self, debug=False):
self.debug = debug
self.metrics = None
self.cached_metrics = load_cache(max_age_minutes=24 * 60)
self.error = None
self.stage = "START"
self.count = 0
self.done = False
self.refreshing = False
self.generation = 0
self.last_success_monotonic = None
self.last_attempt_monotonic = None
self.thread = None
self._lock = threading.Lock()
def progress(self, stage, count):
with self._lock:
self.stage, self.count = stage, count
def start(self):
"""Always fetch fresh data at startup."""
self.refresh(initial=True)
def refresh(self, initial=False):
with self._lock:
if self.refreshing:
return False
self.refreshing = True
self.error = None
self.last_attempt_monotonic = time.monotonic()
if initial:
self.done = False
self.stage = "START"
self.thread = threading.Thread(target=self._run, args=(initial,), daemon=True)
self.thread.start()
return True
def _run(self, initial):
try:
fresh = fetch_live_metrics(self.progress, debug=self.debug)
save_cache(fresh)
with self._lock:
self.metrics = fresh
self.cached_metrics = fresh
self.stage = "READY"
self.done = True
self.generation += 1
self.last_success_monotonic = time.monotonic()
except Exception as e:
with self._lock:
self.error = str(e)
self.stage = "ERROR"
self.done = True
# On startup only, fall back to cache if available.
if initial and self.metrics is None and self.cached_metrics is not None:
self.metrics = self.cached_metrics
self.generation += 1
finally:
with self._lock:
self.refreshing = False
def loading_scene(stage: str, t: float, error: str | None = None):
b = blank()
color = RED if error else CYAN
for x in range(W):
if (x + int(t * 18)) % 12 < 5:
px(b, x, 15, color if not error else DARK_RED)
texts = [
native_text("DATA ERROR" if error else "SYNC BGP STATS", 36, -1,
font="small", color=C_RED if error else C_CYAN, align="top_mid"),
]
if error:
texts.append(native_text(error.upper(), 0, 8, font="tiny", color=C_DIM, width=72,
scroll_rate=420, scroll_start_delay=250, scroll_repeat_delay=700))
else:
texts.append(native_text(stage[:12], 1, 7, font="small", color=C_WHITE))
return b, texts
# ---------------------------------------------------------------------------
# CLI / main
# ---------------------------------------------------------------------------
def parse_args():
p = argparse.ArgumentParser(description="Full Routing Table ticker for BUSY Bar")
p.add_argument("--host", default=DEFAULT_HOST)
mode = p.add_mutually_exclusive_group()
mode.add_argument("--demo", action="store_true", help="use representative demo data instead of live statistics")
mode.add_argument("--live", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--speed", type=float, default=4.5,
help="seconds each screen remains visible (default: 4.5)")
p.add_argument("--fps", type=float, default=8.0,
help="decorative animation frames per second (default: 8)")
p.add_argument("--autorefresh", type=float, default=60.0, metavar="MINUTES",
help="live-data refresh interval in minutes; 0 disables (default: 60)")
p.add_argument("--debug", action="store_true", help="print live-data fetch and parsing diagnostics")
p.add_argument("--once", action="store_true", help="render one frame and exit")
return p.parse_args()
def main():
args = parse_args()
demo = args.demo
viz = Visualizer()
speed = max(0.5, args.speed)
autorefresh_minutes = max(0.0, args.autorefresh)
autorefresh_seconds = autorefresh_minutes * 60.0
loader = None
m = DEMO
if not demo:
loader = LiveLoader(debug=args.debug)
loader.start() # startup is always a fresh network fetch
bb = Busy(args.host)
fps = max(1.0, min(15.0, args.fps))
frame_dt = 1.0 / fps
app_t0 = time.monotonic()
# Scene timing is independent from application uptime. This is important
# after the initial sync: the first completed-data card must always be IPv4.
scene_index = 0
scene_started = None
cycle_generation = -1
print(f"FRT Ticker -> {bb.base} mode={'DEMO' if demo else 'LIVE'} speed={speed:g}s (Ctrl-C to stop)")
if not demo:
ar = "off" if autorefresh_minutes == 0 else f"{autorefresh_minutes:g} min"
print(f"source: APNIC/Thyme summaries autorefresh={ar}")
try:
while True:
start = time.monotonic()
app_elapsed = start - app_t0
if loader is not None:
# Schedule background refreshes from the last successful fetch.
if (autorefresh_seconds > 0 and loader.last_attempt_monotonic is not None
and not loader.refreshing
and start - loader.last_attempt_monotonic >= autorefresh_seconds):
loader.refresh(initial=False)
# Initial synchronization owns the screen until it completes.
if not loader.done:
frame, texts = loading_scene(loader.stage, app_elapsed)
elif loader.metrics is None:
frame, texts = loading_scene(loader.stage, app_elapsed, loader.error)
else:
m = loader.metrics
# On the first usable dataset, explicitly start from IPv4.
# Later autorefresh generations update values in-place without
# disturbing whichever screen the user is currently reading.
if scene_started is None:
scene_index = 0
scene_started = start
cycle_generation = loader.generation
t_scene = start - scene_started
name = SCENES[scene_index]
dwell = scene_duration(name, m, speed)
if t_scene >= dwell:
scene_index = (scene_index + 1) % len(SCENES)
scene_started = start
t_scene = 0.0
frame, texts, _ = render_scene_index(viz, m, scene_index, t_scene)
else:
if scene_started is None:
scene_started = start
t_scene = start - scene_started
name = SCENES[scene_index]
dwell = scene_duration(name, m, speed)
if t_scene >= dwell:
scene_index = (scene_index + 1) % len(SCENES)
scene_started = start
t_scene = 0.0
frame, texts, _ = render_scene_index(viz, m, scene_index, t_scene)
try:
bb.show(frame, texts)
except (urllib.error.URLError, RuntimeError) as e:
print(f"display: {e}", file=sys.stderr)
if args.once:
break
dt = time.monotonic() - start
if dt < frame_dt:
time.sleep(frame_dt - dt)
except KeyboardInterrupt:
print("\nstopped.")
finally:
bb.clear()
if __name__ == "__main__":
main()