77import time
88import json
99import socket
10+ import collections
1011import urllib .parse
1112import urllib .request
1213
1314import psutil
15+ from serial import SerialException
1416from PIL import Image , ImageChops , ImageDraw , ImageFont
1517
1618BASE = 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+
115185def 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 ----------
259458def init_lcd (orientation = "landscape" , brightness = 20 ):
260459 from library .lcd .lcd_comm_rev_a import LcdCommRevA , Orientation
0 commit comments