Skip to content

Commit eeb9dc4

Browse files
Fumunchuclaude
andcommitted
Add graph/cores widgets, net rates, RAPL watts, backlight schedule, serial reconnect
Addressing recurring upstream asks from the open-issue tracker: - graph widget: single/multi-series line history, right-anchored, sampled while the page is visible (mathoudebine#715) - cores widget: per-core CPU mini-bars (mathoudebine#729) - local values: net_down/net_up with auto-scaled human units (mathoudebine#447, mathoudebine#720, mathoudebine#152), cpu_watts via intel RAPL (mathoudebine#254; unit grants sysfs read via root ExecStartPre), fan_rpm (mathoudebine#192), nvme_temp (mathoudebine#182) - screen.schedule in display.yaml: dim or switch off the backlight in time windows -- night/away mode (mathoudebine#531, mathoudebine#687) - push_frame reopens the serial port and re-pushes on SerialException (mathoudebine#562-style wedges) instead of dying - HOST page gains cores + net rates; new TRENDS page with 4 graphs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b2a9dc5 commit eeb9dc4

7 files changed

Lines changed: 257 additions & 13 deletions

File tree

DISPLAY.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,21 @@ Home Assistant, kubectl, etc. is one small class in `apps/common.py`
4040
## Page schema
4141

4242
See the comment block at the top of `pages.yaml` — widgets (`bar`, `metric`,
43-
`radial`, `text`), format strings over a value namespace (local psutil values
44-
+ per-page query results + derived expressions), threshold/conditional color
45-
specs, optional `background:` art from any stock theme (the shipped CYBERDECK
46-
page traces live radial gauges over the stock Cyberdeck theme art), and
47-
`type: netmap` pages that embed the netmap app in the rotation.
43+
`radial`, `text`, `graph`, `cores`), format strings over a value namespace
44+
(local psutil values + per-page query results + derived expressions),
45+
threshold/conditional color specs, optional `background:` art from any stock
46+
theme (the shipped CYBERDECK page traces live radial gauges over the stock
47+
Cyberdeck theme art), and `type: netmap` pages that embed the netmap app in
48+
the rotation.
49+
50+
Local values include network rates with auto-scaled units (`net_down_h`),
51+
per-core CPU (`cpu_cores`), CPU package power via RAPL (`cpu_watts`),
52+
`fan_rpm` and `nvme_temp` where the platform exposes them.
53+
54+
`screen.schedule` in `display.yaml` dims or switches the panel off during
55+
time windows (night/away mode). Serial hiccups are handled by reopening the
56+
port and re-pushing the frame (panels are known to wedge after long sessions,
57+
upstream #562).
4858

4959
## Preview without a screen
5060

apps/carousel.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,13 @@ def run(lcd, display_cfg):
9292
import psutil
9393
psutil.cpu_percent() # prime the first reading
9494

95+
sched = c.ScreenScheduler(lcd, display_cfg.get("screen"))
9596
idx = 0
9697
prev = None # last frame on the panel; every push is a diff against it
9798
while True:
99+
if not sched.tick(): # night/away window: panel off, check back shortly
100+
time.sleep(30)
101+
continue
98102
try:
99103
cfg = load_pages_cfg(pages_file) # live reload; keep last good on error
100104
except Exception:

apps/common.py

Lines changed: 206 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
import time
88
import json
99
import socket
10+
import collections
1011
import urllib.parse
1112
import urllib.request
1213

1314
import psutil
15+
from serial import SerialException
1416
from PIL import Image, ImageChops, ImageDraw, ImageFont
1517

1618
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # repo root
@@ -112,10 +114,90 @@ def fmt_uptime():
112114
return f"{d}d {h}h" if d else f"{h}h {s // 60}m"
113115

114116

117+
_RATE_STATE = {}
118+
119+
120+
def _delta_rate(key, current, now):
121+
"""Per-second rate from a monotonically increasing counter; None on first call."""
122+
prev = _RATE_STATE.get(key)
123+
_RATE_STATE[key] = (current, now)
124+
if prev is None:
125+
return None
126+
dv, dt = current - prev[0], now - prev[1]
127+
return dv / dt if dt > 0 and dv >= 0 else None
128+
129+
130+
_NET_SKIP = ("lo", "veth", "docker", "br-", "cni", "flannel", "kube", "tailscale")
131+
132+
133+
def _net_counters():
134+
"""Aggregate rx/tx bytes over physical-ish NICs (skips loopback/bridge/CNI)."""
135+
rx = tx = 0
136+
for name, io in psutil.net_io_counters(pernic=True).items():
137+
if any(name.startswith(p) for p in _NET_SKIP):
138+
continue
139+
rx += io.bytes_recv
140+
tx += io.bytes_sent
141+
return rx, tx
142+
143+
144+
def human_rate(bps):
145+
if bps is None:
146+
return "-"
147+
for unit in ("B/s", "KB/s", "MB/s", "GB/s"):
148+
if bps < 1000 or unit == "GB/s":
149+
break
150+
bps /= 1000.0
151+
return f"{bps:.0f} {unit}" if unit == "B/s" else f"{bps:.1f} {unit}"
152+
153+
154+
def _cpu_watts(now):
155+
"""CPU package power from intel RAPL (None if unreadable/AMD without rapl)."""
156+
try:
157+
with open("/sys/class/powercap/intel-rapl:0/energy_uj") as f:
158+
uj = int(f.read())
159+
except Exception:
160+
return None
161+
rate = _delta_rate("rapl_uj", uj, now)
162+
return rate / 1e6 if rate is not None else None
163+
164+
165+
def _fan_rpm():
166+
try:
167+
for entries in psutil.sensors_fans().values():
168+
if entries:
169+
return entries[0].current
170+
except Exception:
171+
pass
172+
return None
173+
174+
175+
def _nvme_temp():
176+
try:
177+
t = psutil.sensors_temperatures().get("nvme")
178+
if t:
179+
return max(s.current for s in t)
180+
except Exception:
181+
pass
182+
return None
183+
184+
115185
def local_values():
116186
vm = psutil.virtual_memory()
117187
du = psutil.disk_usage("/")
188+
now = time.monotonic()
189+
rx, tx = _net_counters()
190+
net_down = _delta_rate("net_rx", rx, now)
191+
net_up = _delta_rate("net_tx", tx, now)
118192
return {
193+
"net_down": net_down,
194+
"net_up": net_up,
195+
"net_down_h": human_rate(net_down),
196+
"net_up_h": human_rate(net_up),
197+
"cpu_cores": psutil.cpu_percent(percpu=True),
198+
"cpu_watts": _cpu_watts(now),
199+
"fan_rpm": _fan_rpm(),
200+
"nvme_temp": _nvme_temp(),
119201
"cpu_pct": psutil.cpu_percent(),
120202
"ram_pct": vm.percent,
121203
"ram_used_g": vm.used / 1e9,
@@ -232,16 +314,67 @@ def draw_text(d, w, vals):
232314
fill=resolve_color(w.get("color"), vals), anchor=w.get("anchor", "mm"))
233315

234316

235-
WIDGETS = {"bar": draw_bar, "metric": draw_metric, "radial": draw_radial, "text": draw_text}
317+
_HISTORY = {}
318+
_SERIES_COLORS = ("accent", "ok", "warn", "bad")
236319

237320

238-
def push_frame(lcd, new, old=None, band=20):
239-
"""Push only the horizontal bands of `new` that differ from `old`.
321+
def draw_graph(d, w, vals):
322+
"""Line graph over a value's recent history (sampled while the page shows).
323+
Single series via `value:`, multiple via `values: [a, b]` (+ `colors:`)."""
324+
x, y = w["x"], w["y"]
325+
gw, gh = w.get("w", 200), w.get("h", 60)
326+
samples = int(w.get("samples", 60))
327+
series = w.get("values") or ([w["value"]] if w.get("value") else [])
328+
colors = w.get("colors") or []
329+
d.rectangle([x, y, x + gw, y + gh], outline=TRACK)
330+
if w.get("label"):
331+
d.text((x, y - 18), str(w["label"]), font=font(13, "Medium"),
332+
fill=COLORS["dim"], anchor="la")
333+
if w.get("text"):
334+
d.text((x + gw, y - 18), fmt(w["text"], vals), font=font(13, "Medium"),
335+
fill=COLORS["fg"], anchor="ra")
336+
vmin = float(w.get("min", 0))
337+
for si, name in enumerate(series):
338+
key = f"{name}@{x},{y}"
339+
hist = _HISTORY.get(key)
340+
if hist is None or hist.maxlen != samples:
341+
hist = _HISTORY[key] = collections.deque(hist or [], maxlen=samples)
342+
v = vals.get(name)
343+
if isinstance(v, (int, float)):
344+
hist.append(float(v))
345+
if len(hist) < 2:
346+
continue
347+
vmax = float(w["max"]) if w.get("max") is not None else (max(hist) or 1.0)
348+
span = max(vmax - vmin, 1e-9)
349+
pad = samples - len(hist) # anchor newest sample to the right edge
350+
pts = [(x + gw * (pad + i) / (samples - 1),
351+
y + gh - gh * min(max((val - vmin) / span, 0.0), 1.0))
352+
for i, val in enumerate(hist)]
353+
cname = colors[si] if si < len(colors) else _SERIES_COLORS[si % len(_SERIES_COLORS)]
354+
d.line(pts, fill=COLORS.get(cname, COLORS["accent"]), width=2)
355+
356+
357+
def draw_cores(d, w, vals):
358+
"""One mini vertical bar per CPU core."""
359+
cores = vals.get("cpu_cores") or []
360+
x, y, h = w["x"], w["y"], w.get("h", 40)
361+
bw, gap = w.get("bar_w", 10), w.get("gap", 4)
362+
if w.get("label"):
363+
d.text((x, y - 18), str(w["label"]), font=font(13, "Medium"),
364+
fill=COLORS["dim"], anchor="la")
365+
for i, p in enumerate(cores):
366+
cx = x + i * (bw + gap)
367+
d.rectangle([cx, y, cx + bw, y + h], fill=TRACK)
368+
fh = int(h * min(p, 100) / 100)
369+
if fh:
370+
d.rectangle([cx, y + h - fh, cx + bw, y + h], fill=threshold_color(p))
240371

241-
This is what keeps the screen wipe-free: value ticks push a few tiny
242-
rectangles, and even page transitions skip unchanged chrome and blank
243-
space. With old=None the full frame is pushed (first frame only).
244-
"""
372+
373+
WIDGETS = {"bar": draw_bar, "metric": draw_metric, "radial": draw_radial,
374+
"text": draw_text, "graph": draw_graph, "cores": draw_cores}
375+
376+
377+
def _push_bands(lcd, new, old, band):
245378
if old is None:
246379
lcd.DisplayPILImage(new, 0, 0)
247380
return
@@ -255,6 +388,72 @@ def push_frame(lcd, new, old=None, band=20):
255388
lcd.DisplayPILImage(new.crop((x0, y0 + by0, x1, y0 + by1)), x0, y0 + by0)
256389

257390

391+
def push_frame(lcd, new, old=None, band=20):
392+
"""Push only the horizontal bands of `new` that differ from `old`.
393+
394+
Value ticks push a few tiny rectangles; page transitions skip unchanged
395+
chrome and blank space. With old=None the full frame is pushed.
396+
397+
On a serial hiccup (devices are known to wedge after hours, upstream #562)
398+
the port is reopened once and the full frame re-pushed.
399+
"""
400+
try:
401+
_push_bands(lcd, new, old, band)
402+
except (SerialException, OSError):
403+
try:
404+
lcd.closeSerial()
405+
except Exception:
406+
pass
407+
time.sleep(2)
408+
lcd.openSerial()
409+
_push_bands(lcd, new, None, band) # panel state unknown -> full redraw
410+
411+
412+
def _parse_hm(s):
413+
hh, mm = str(s).split(":")
414+
return int(hh) * 60 + int(mm)
415+
416+
417+
def _in_window(now_min, frm, to):
418+
return frm <= now_min < to if frm <= to else (now_min >= frm or now_min < to)
419+
420+
421+
class ScreenScheduler:
422+
"""Applies `screen.schedule` windows from display.yaml: dim the backlight
423+
or switch the panel off during time ranges (night/away mode)."""
424+
425+
def __init__(self, lcd, screen_cfg):
426+
self.lcd = lcd
427+
cfg = screen_cfg or {}
428+
self.default = int(cfg.get("brightness", 20))
429+
self.windows = cfg.get("schedule") or []
430+
self.applied = ("on", self.default)
431+
432+
def tick(self):
433+
"""Apply the window for the current time; False while the panel is off."""
434+
t = time.localtime()
435+
now_min = t.tm_hour * 60 + t.tm_min
436+
state = ("on", self.default)
437+
for win in self.windows:
438+
try:
439+
if _in_window(now_min, _parse_hm(win.get("from", "00:00")),
440+
_parse_hm(win.get("to", "00:00"))):
441+
state = ("off", 0) if win.get("off") \
442+
else ("on", int(win.get("brightness", self.default)))
443+
break
444+
except Exception:
445+
continue
446+
if state != self.applied:
447+
if state[0] == "off":
448+
self.lcd.ScreenOff()
449+
else:
450+
if self.applied[0] == "off":
451+
self.lcd.ScreenOn()
452+
self.lcd.SetBrightness(state[1])
453+
self.applied = state
454+
return state[0] == "on"
455+
456+
258457
# ---------- lcd ----------
259458
def init_lcd(orientation="landscape", brightness=20):
260459
from library.lcd.lcd_comm_rev_a import LcdCommRevA, Orientation

apps/netmap.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,12 @@ def run(lcd, display_cfg):
8080

8181
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
8282
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
83+
sched = c.ScreenScheduler(lcd, display_cfg.get("screen"))
8384
prev = None
8485
while True:
86+
if not sched.tick():
87+
time.sleep(30)
88+
continue
8589
frame = render(gather_hosts(src, nm_cfg), nm_cfg)
8690
c.push_frame(lcd, frame, prev)
8791
prev = frame

display.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ mode: carousel # standard | carousel | netmap
66
screen:
77
orientation: landscape # landscape | reverse_landscape
88
brightness: 20
9+
# Night/away mode: dim or switch the panel off during time windows.
10+
# Windows are checked every loop tick; first match wins. Applied on restart.
11+
# schedule:
12+
# - {from: "22:30", to: "06:30", brightness: 5}
13+
# - {from: "01:00", to: "06:00", off: true}
914

1015
# Named data sources. Apps reference these by name; `prom` is the default.
1116
# Add more prometheus endpoints (or future source types) here.

pages.yaml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
# radial: {type: radial, x, y, r, width, pct: <value-name>, text, size, label, track: true}
1212
# arc gauge from 12 o'clock; omit `track` to trace background ring art
1313
# text: {type: text, x, y, size, weight, family, anchor, text: "<format>", color: <spec>}
14+
# graph: {type: graph, x, y, w, h, value: <name> | values: [..], colors: [..],
15+
# min, max, samples, label, text} -- history sampled while page is visible
16+
# cores: {type: cores, x, y, h, bar_w, gap, label} -- per-core CPU mini-bars
1417
#
1518
# Page types:
1619
# (default) widget page as below
@@ -26,7 +29,9 @@
2629
#
2730
# text format: python format string over the value namespace; unavailable -> "-".
2831
# Local values (always): cpu_pct ram_pct ram_used_g ram_total_g disk_pct
29-
# disk_used_g disk_total_g temp load1 uptime
32+
# disk_used_g disk_total_g temp load1 uptime time_hm
33+
# net_down net_up (B/s) net_down_h net_up_h (auto-unit)
34+
# cpu_cores (list) cpu_watts (RAPL) fan_rpm nvme_temp
3035
#
3136
# color spec:
3237
# fg | dim | accent | ok | warn | bad fixed color
@@ -45,6 +50,20 @@ pages:
4550
- {type: metric, x: 300, y: 58, label: TEMP, text: "{temp:.0f}°C", color: "auto:temp:75:90"}
4651
- {type: metric, x: 300, y: 138, label: LOAD, text: "{load1:.2f}"}
4752
- {type: metric, x: 300, y: 218, label: UPTIME, text: "{uptime}"}
53+
- {type: cores, x: 16, y: 248, h: 36, bar_w: 10, gap: 4, label: CORES}
54+
- {type: text, x: 150, y: 254, size: 13, anchor: lm, text: "DN {net_down_h}"}
55+
- {type: text, x: 150, y: 274, size: 13, anchor: lm, text: "UP {net_up_h}"}
56+
57+
- title: TRENDS
58+
widgets:
59+
- {type: graph, x: 16, y: 84, w: 215, h: 80, values: [cpu_pct, ram_pct], colors: [accent, ok],
60+
max: 100, samples: 90, label: CPU / RAM, text: "{cpu_pct:.0f}% / {ram_pct:.0f}%"}
61+
- {type: graph, x: 250, y: 84, w: 215, h: 80, value: temp, min: 30, max: 90,
62+
samples: 90, label: TEMP, text: "{temp:.0f}°C"}
63+
- {type: graph, x: 16, y: 205, w: 215, h: 80, value: net_down,
64+
samples: 90, label: NET DOWN, text: "{net_down_h}"}
65+
- {type: graph, x: 250, y: 205, w: 215, h: 80, value: net_up,
66+
samples: 90, label: NET UP, text: "{net_up_h}"}
4867

4968
- title: K3S CLUSTER
5069
queries:

systemd/turing-display.service

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Type=simple
88
User=jacquesb
99
SupplementaryGroups=dialout
1010
WorkingDirectory=/home/jacquesb/turing-smart-screen
11+
# RAPL energy counters are root-only on recent kernels; grant read so the
12+
# cpu_watts value works (runs as root via the '+' prefix, harmless if absent)
13+
ExecStartPre=+-/usr/bin/chmod o+r /sys/class/powercap/intel-rapl:0/energy_uj
1114
ExecStart=/home/jacquesb/turing-smart-screen/venv/bin/python display.py
1215
Restart=always
1316
RestartSec=5

0 commit comments

Comments
 (0)