Mac Monitor
Mac system monitor - CPU, memory and network as live bars.
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
"""Mac system monitor: CPU, memory and network as live bars.
python app.py # BUSY Bar over USB (always 10.0.4.20)
python app.py --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
python app.py --metrics HOST:PORT # read the Mac through a host agent
"""
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
APP = "mac-monitor"
# ---------------------------------------------------------------------------
# BUSY Bar HTTP API — self-contained, stdlib only.
# Over USB the bar is always at 10.0.4.20; --host targets a Wi-Fi bar or the
# emulator. Full API docs are served by the device: http://10.0.4.20/docs
# ---------------------------------------------------------------------------
def _arg(name, default=""):
if name in sys.argv:
i = sys.argv.index(name)
if i + 1 < len(sys.argv):
return sys.argv[i + 1]
return default
BASE = "http://" + _arg("--host", "10.0.4.20").replace("http://", "").rstrip("/")
def draw(elements, **extra):
body = {"application_name": APP, "elements": elements, **extra}
req = urllib.request.Request(BASE + "/api/display/draw",
data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5):
pass
def text(txt, x=0, y=0, font="normal", color="#FFFFFFFF", **kw):
return {"type": "text", "text": str(txt), "x": x, "y": y, "font": font, "color": color, **kw}
def rectangle(x, y, width, height, **kw):
return {"type": "rectangle", "x": x, "y": y, "width": width, "height": height, **kw}
# ---------------------------------------------------------------------------
# System metrics
# ---------------------------------------------------------------------------
# State carried between ticks for network rate calculation.
_prev_bytes = 0
_prev_time = 0.0
_last = {"cpu": 0.0, "mem": 0.0, "net_rate": 0.0}
def _run(*args):
"""Run a command and return stdout, or '' on failure."""
try:
r = subprocess.run(list(args), capture_output=True, text=True, timeout=3)
return r.stdout
except Exception:
return ""
def _cpu_pct():
"""Return CPU % (0-100) as a float, capped at 100."""
try:
ps_out = _run("ps", "-A", "-o", "%cpu")
total = sum(float(l) for l in ps_out.splitlines() if l.strip() not in ("", "%CPU"))
ncpu_out = _run("sysctl", "-n", "hw.ncpu").strip()
ncpu = int(ncpu_out) if ncpu_out else 1
return min(100.0, total / max(ncpu, 1))
except Exception:
return _last["cpu"]
def _mem_pct():
"""Return RAM % (0-100) as a float."""
try:
vm = _run("vm_stat")
# Parse page size from header: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
page_size = 4096
for line in vm.splitlines():
if "page size of" in line:
parts = line.split()
idx = parts.index("of") + 1
page_size = int(parts[idx])
break
def _pages(key):
for line in vm.splitlines():
if line.startswith(key):
val = line.split(":")[1].strip().rstrip(".")
return int(val)
return 0
active = _pages("Pages active")
wired = _pages("Pages wired down")
compressed = _pages("Pages occupied by compressor")
used_bytes = (active + wired + compressed) * page_size
memsize_out = _run("sysctl", "-n", "hw.memsize").strip()
total_bytes = int(memsize_out) if memsize_out else 1
return min(100.0, used_bytes / total_bytes * 100.0)
except Exception:
return _last["mem"]
def _net_bytes():
"""Return total ibytes+obytes across all physical interfaces."""
try:
out = _run("netstat", "-ib")
seen = set()
total = 0
for line in out.splitlines():
parts = line.split()
# Header line or too-short lines: skip.
if len(parts) < 10 or parts[0] == "Name":
continue
iface = parts[0]
# Skip loopback and duplicate interface rows (keep first occurrence only).
if iface == "lo0" or iface in seen:
continue
# netstat -ib columns: Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Drop
# Ibytes = index 6, Obytes = index 9
try:
ibytes = int(parts[6])
obytes = int(parts[9])
total += ibytes + obytes
seen.add(iface)
except (ValueError, IndexError):
continue
return total
except Exception:
return 0
# ---------------------------------------------------------------------------
# Where the readings come from
#
# Off this Mac, unless the app is running somewhere that cannot see it. Under
# busybar-manager's Docker setup it runs in a Linux container, where ps,
# vm_stat, sysctl and netstat are either missing or describe the container,
# and _run() turns each of those into "". That reads exactly like an idle Mac:
# three bars at 0%. So when the local tools come up empty the app asks a host
# agent instead (busybar-manager/scripts/host-metrics.py), which Docker
# Desktop exposes as host.docker.internal. Override the address with
# --metrics HOST:PORT or BUSYBAR_HOST_METRICS.
# ---------------------------------------------------------------------------
DEFAULT_AGENT = "host.docker.internal:8322"
# Set once by _resolve_source(); None means this Mac is readable directly.
_agent = None
_agent_ok = True
def _reads_this_mac():
return _run("sysctl", "-n", "hw.memsize").strip().isdigit()
def _fetch(agent):
"""(cpu %, mem %, cumulative net bytes) from a host metrics agent."""
with urllib.request.urlopen(f"http://{agent}/metrics", timeout=4) as r:
d = json.loads(r.read().decode())
return float(d["cpu_pct"]), float(d["mem_pct"]), int(d["net_bytes"])
def _resolve_source():
"""Pick the source once, and refuse to start rather than draw zeroes."""
requested = _arg("--metrics", os.environ.get("BUSYBAR_HOST_METRICS", ""))
if not requested and _reads_this_mac():
return None
agent = requested or DEFAULT_AGENT
# App and agent come up together at login, so a first miss usually means
# "not yet" rather than "not there". Give it a few tries before quitting.
for attempt in range(5):
if attempt:
time.sleep(3)
try:
_fetch(agent)
return agent
except Exception as e:
last_error = e
sys.exit(
f"error: this Mac is not readable from here, and the host agent at "
f"{agent} is not answering ({last_error}).\n"
"Running in a container? Start the agent on the Mac itself:\n"
" python3 scripts/host-metrics.py (in busybar-manager)\n"
"or point at one with --metrics HOST:PORT."
)
def _sample():
"""One reading, or None while the agent is briefly unreachable."""
global _agent_ok
if _agent is None:
return _cpu_pct(), _mem_pct(), _net_bytes()
try:
reading = _fetch(_agent)
except Exception as e:
if _agent_ok:
print(f"host metrics unreachable ({_agent}): {e}, holding last reading",
file=sys.stderr, flush=True)
_agent_ok = False
return None
if not _agent_ok:
print(f"host metrics back ({_agent})", flush=True)
_agent_ok = True
return reading
def _color(pct_or_frac_x100):
"""Green / orange / red based on percentage."""
if pct_or_frac_x100 >= 90:
return "#E60022FF"
if pct_or_frac_x100 >= 70:
return "#F59E0BFF"
return "#22C55EFF"
def _fmt_net(rate):
"""Auto-scale bytes/s to B/K/M/G with one decimal under 10."""
for unit, threshold in (("G", 1024**3), ("M", 1024**2), ("K", 1024)):
if rate >= threshold:
val = rate / threshold
return f"{val:.1f}{unit}" if val < 10 else f"{val:.0f}{unit}"
return f"{rate:.0f}B"
def tick():
global _prev_bytes, _prev_time
sample = _sample()
if sample is None:
# Nothing new to read: redraw the last reading rather than a zeroed
# display, and leave _prev_* alone so the next rate spans the gap.
cpu, mem, net_rate = _last["cpu"], _last["mem"], _last["net_rate"]
else:
cpu, mem, nb = sample
now = time.monotonic()
if _prev_time == 0.0:
net_rate = 0.0
else:
elapsed = now - _prev_time
delta = nb - _prev_bytes
net_rate = max(0.0, delta / elapsed) if elapsed > 0 else 0.0
_prev_bytes = nb
_prev_time = now
_last["cpu"] = cpu
_last["mem"] = mem
_last["net_rate"] = net_rate
net_frac = min(1.0, net_rate / (10 * 1024 * 1024))
elements = []
for row_y, label, pct, frac, value_str in (
(0, "CPU", cpu, cpu / 100.0, f"{round(cpu)}%"),
(5, "MEM", mem, mem / 100.0, f"{round(mem)}%"),
(10, "NET", net_frac * 100, net_frac, _fmt_net(net_rate)),
):
col = _color(pct)
key = label.lower()
# Label
elements.append(text(label, x=0, y=row_y, font="tiny", color="#FFFFFFFF",
id=f"{key}_label"))
# Bar outline (no fill, subtle border). Positioned with ≥1px clearance on
# both sides: the widest label ("MEM", 16px, ends x=15) and the widest
# right-aligned value ("1023K", left edge x=50).
elements.append(rectangle(
x=17, y=row_y, width=31, height=5,
border_width=1, border_color="#505050FF",
id=f"{key}_bar_outline",
))
# Bar fill (solid, no border)
fill_w = round(29 * frac)
if fill_w >= 1:
elements.append(rectangle(
x=18, y=row_y + 1, width=fill_w, height=3,
border_width=0, fill="solid", fill_colors=[col],
id=f"{key}_bar_fill",
))
# Value text, right-aligned at x=71
elements.append(text(value_str, x=71, y=row_y, font="tiny",
align="top_right", color=col, id=f"{key}_value"))
try:
draw(elements)
except urllib.error.HTTPError as e:
if e.code != 409: # 409 = a higher-priority app owns the display
raise
if __name__ == "__main__":
_agent = _resolve_source()
source = "this Mac" if _agent is None else f"host agent {_agent}"
print(f"mac_monitor → {BASE} (reading {source}, Ctrl-C to stop)")
try:
while True:
tick()
time.sleep(2.0)
except KeyboardInterrupt:
print("\nstopped.")
except urllib.error.HTTPError as e:
sys.exit(f"error: HTTP {e.code} — {e.read().decode('utf-8', 'ignore')}")
except urllib.error.URLError as e:
sys.exit(f"error: cannot reach {BASE} — {e.reason}")