code.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. # LuxStats firmware v3 - Pimoroni Tiny 2040 + NeoPixels (CircuitPython 8/9)
  2. #
  3. # Config lives in RAM only; the host configurator owns it and re-pushes it on
  4. # connect and on every edit.
  5. #
  6. # Segment types:
  7. # percent 0.0-1.0 value -> gradient (optional per-segment "gradient")
  8. # temp RAW Celsius value -> gradient positioned by min_c/max_c
  9. # link Ethernet speed -> POSITION palette "colors"
  10. # positions: [down, 10M, 100M, 1G, 2.5G, 10G]
  11. # wifi Wi-Fi band -> POSITION palette "colors"
  12. # positions: [down, 2.4GHz, 5GHz, 6GHz]
  13. # vpn/bool on_color / off_color
  14. # custom pushed [r,g,b,w] value, else fallback "color"
  15. # ambient effect zone: off|solid|breathe|rainbow|sparkle + color + speed
  16. #
  17. # Camera glow: an ASSIGNED LED list (host sends expanded indices) with the
  18. # same effect presets as ambient zones, rendered as an overlay when active.
  19. #
  20. # Overlay order: segments -> camera glow -> notification blinker -> test blink
  21. #
  22. # Protocol (host -> device), one JSON object per line:
  23. # {"cmd":"ping"} {"cmd":"config","config":{...}} {"cmd":"get_config"}
  24. # {"cmd":"status","values":{...}} silent unless "ack":1
  25. # {"cmd":"notify","color":[..],"period":s,"count":n} n -1 = until cleared
  26. # {"cmd":"notify_clear"} {"cmd":"camera","active":bool}
  27. # {"cmd":"test","start":i,"count":n,"active":bool}
  28. # {"cmd":"raw","pixels":[[..],...]} {"cmd":"raw_off"}
  29. import json
  30. import math
  31. import random
  32. import time
  33. import board
  34. import neopixel
  35. import usb_cdc
  36. LINK_TIERS = [0, 10, 100, 1000, 2500, 10000]
  37. DEFAULT_LINK_COLORS = [
  38. [0, 0, 0, 0], [255, 0, 0, 0], [255, 90, 0, 0],
  39. [0, 255, 0, 0], [0, 255, 180, 0], [80, 0, 255, 0],
  40. ]
  41. WIFI_BANDS = [0, 2, 5, 6]
  42. DEFAULT_WIFI_COLORS = [
  43. [0, 0, 0, 0], [255, 120, 0, 0], [0, 255, 60, 0], [40, 80, 255, 0],
  44. ]
  45. DEFAULT_CONFIG = {
  46. "num_pixels": 8,
  47. "pin": "GP0",
  48. "pixel_order": "GRBW",
  49. "brightness": 0.10,
  50. "fps": 30,
  51. "segments": [
  52. {"name": "cpu", "type": "percent", "start": 0, "count": 2,
  53. "fill": True,
  54. "gradient": [[0.0, [0, 0, 255, 0]], [0.5, [0, 255, 0, 0]],
  55. [1.0, [255, 0, 0, 0]]]},
  56. {"name": "eth", "type": "link", "start": 2, "count": 1},
  57. {"name": "wifi", "type": "wifi", "start": 3, "count": 1},
  58. {"name": "vpn", "type": "vpn", "start": 4, "count": 1},
  59. {"name": "glow", "type": "ambient", "start": 5, "count": 3,
  60. "effect": "breathe", "color": [10, 10, 30, 0], "speed": 1.0},
  61. ],
  62. "camera": {"pixels": [5, 6, 7], "color": [255, 40, 0, 0],
  63. "effect": "breathe", "speed": 1.0},
  64. "notify": {"start": 0, "count": 0, "blinks": 6,
  65. "default_color": [0, 60, 255, 0], "default_period": 0.4},
  66. }
  67. TEST_TIMEOUT = 180.0
  68. def clamp(x, a, b):
  69. return a if x < a else (b if x > b else x)
  70. def lerp(a, b, t):
  71. return a + (b - a) * t
  72. def pad4(c):
  73. c = list(c)[:4]
  74. return c + [0] * (4 - len(c))
  75. def scale(color, b):
  76. return tuple(int(c * b) for c in color)
  77. def default_percent_color(t):
  78. t = clamp(t, 0.0, 1.0)
  79. if t < 0.33:
  80. f = t / 0.33
  81. r, g, b = 0, int(255 * f), int(255 * (1 - f))
  82. elif t < 0.66:
  83. f = (t - 0.33) / 0.33
  84. r, g, b = int(255 * f), 255, 0
  85. else:
  86. f = (t - 0.66) / 0.34
  87. r, g, b = 255, int(255 * (1 - f)), 0
  88. return (r, g, b, 0)
  89. def gradient_color(stops, t):
  90. if not stops:
  91. return default_percent_color(t)
  92. stops = sorted(stops, key=lambda s: s[0])
  93. t = clamp(t, 0.0, 1.0)
  94. if t <= stops[0][0]:
  95. return tuple(pad4(stops[0][1]))
  96. for i in range(1, len(stops)):
  97. p0, c0 = stops[i - 1][0], pad4(stops[i - 1][1])
  98. p1, c1 = stops[i][0], pad4(stops[i][1])
  99. if t <= p1:
  100. f = 0.0 if p1 == p0 else (t - p0) / (p1 - p0)
  101. return tuple(int(lerp(a, b, f)) for a, b in zip(c0, c1))
  102. return tuple(pad4(stops[-1][1]))
  103. def palette_color(seg, defaults, index):
  104. """Positional palette lookup (link/wifi): one color per position."""
  105. colors = seg.get("colors") or defaults
  106. index = int(clamp(index, 0, len(colors) - 1))
  107. return tuple(pad4(colors[index]))
  108. def wheel(pos):
  109. pos = int(pos) & 255
  110. if pos < 85:
  111. return (255 - pos * 3, pos * 3, 0, 0)
  112. if pos < 170:
  113. pos -= 85
  114. return (0, 255 - pos * 3, pos * 3, 0)
  115. pos -= 170
  116. return (pos * 3, 0, 255 - pos * 3, 0)
  117. class LuxStats:
  118. def __init__(self):
  119. self.serial = usb_cdc.data or usb_cdc.console
  120. # Non-blocking IO: a blocked write must never stall the loop
  121. if self.serial is not None:
  122. try:
  123. self.serial.timeout = 0
  124. self.serial.write_timeout = 0
  125. except (AttributeError, ValueError):
  126. pass
  127. self.buf = b""
  128. self.pixels = None
  129. self.statuses = {}
  130. self.notify = None
  131. self.camera_active = False
  132. self.test = None
  133. self.raw = None
  134. self.sparkles = {} # zone key -> [[pixel, t0], ...]
  135. self.apply_config(DEFAULT_CONFIG)
  136. # ---------- config ----------
  137. def apply_config(self, cfg):
  138. merged = dict(DEFAULT_CONFIG)
  139. merged.update(cfg or {})
  140. self.config = merged
  141. self.sparkles = {}
  142. if self.pixels is not None:
  143. try:
  144. self.pixels.deinit()
  145. except Exception:
  146. pass
  147. pin = getattr(board, merged.get("pin", "GP0"), board.GP0)
  148. order = merged.get("pixel_order", "GRBW")
  149. self.bpp = 4 if "W" in order else 3
  150. self.pixels = neopixel.NeoPixel(
  151. pin, int(merged["num_pixels"]),
  152. brightness=1.0, auto_write=False, pixel_order=order,
  153. )
  154. self.frame = [(0, 0, 0, 0)] * int(merged["num_pixels"])
  155. # ---------- serial ----------
  156. def poll_serial(self):
  157. s = self.serial
  158. if s is None:
  159. return
  160. n = s.in_waiting
  161. if n:
  162. self.buf += s.read(n)
  163. while b"\n" in self.buf:
  164. line, self.buf = self.buf.split(b"\n", 1)
  165. line = line.strip()
  166. if line:
  167. self.handle_line(line)
  168. if len(self.buf) > 8192:
  169. self.buf = b""
  170. def send(self, obj):
  171. try:
  172. self.serial.write((json.dumps(obj) + "\n").encode())
  173. except Exception:
  174. pass
  175. def handle_line(self, line):
  176. try:
  177. msg = json.loads(line)
  178. except ValueError:
  179. self.send({"err": "bad json"})
  180. return
  181. cmd = msg.get("cmd", "")
  182. if cmd == "ping":
  183. self.send({"pong": 1, "num_pixels": self.config["num_pixels"],
  184. "fw": 3})
  185. elif cmd == "config":
  186. self.apply_config(msg.get("config", {}))
  187. self.send({"ok": "config"})
  188. elif cmd == "get_config":
  189. self.send({"config": self.config})
  190. elif cmd == "status":
  191. vals = msg.get("values", {})
  192. if isinstance(vals, dict):
  193. self.statuses.update(vals)
  194. if msg.get("ack"):
  195. self.send({"ok": "status"})
  196. elif cmd == "notify":
  197. nz = self.config["notify"]
  198. self.notify = {
  199. "color": tuple(pad4(msg.get("color",
  200. nz["default_color"]))),
  201. "period": float(msg.get("period", nz["default_period"])),
  202. "count": int(msg.get("count", nz.get("blinks", 6))),
  203. "t0": time.monotonic(),
  204. }
  205. self.send({"ok": "notify"})
  206. elif cmd == "notify_clear":
  207. self.notify = None
  208. self.send({"ok": "notify_clear"})
  209. elif cmd == "camera":
  210. self.camera_active = bool(msg.get("active", False))
  211. self.send({"ok": "camera"})
  212. elif cmd == "test":
  213. if msg.get("active", True):
  214. self.test = {"start": int(msg.get("start", 0)),
  215. "count": int(msg.get("count", 1)),
  216. "t0": time.monotonic()}
  217. else:
  218. self.test = None
  219. self.send({"ok": "test"})
  220. elif cmd == "raw":
  221. self.raw = [tuple(pad4(p)) for p in msg.get("pixels", [])]
  222. self.send({"ok": "raw"})
  223. elif cmd == "raw_off":
  224. self.raw = None
  225. self.send({"ok": "raw_off"})
  226. else:
  227. self.send({"err": "unknown cmd '%s'" % cmd})
  228. # ---------- effect helper (ambient zones + camera glow) ----------
  229. def effect_color(self, key, effect, base, speed, now, k, idxs):
  230. """Color for the k-th pixel of an effect zone at time `now`."""
  231. if effect == "solid" or effect == "off":
  232. return base
  233. if effect == "breathe":
  234. t = 0.5 + 0.5 * math.sin(now * 2.0 * speed + k * 0.4)
  235. return scale(base, 0.15 + 0.85 * t)
  236. if effect == "rainbow":
  237. return wheel(now * 40 * speed + k * (256 // max(len(idxs), 1)))
  238. if effect == "sparkle":
  239. return scale(base, 0.10)
  240. return base
  241. def apply_sparkles(self, key, idxs, speed, now):
  242. sp = self.sparkles.setdefault(key, [])
  243. if idxs and random.random() < 0.15 * speed:
  244. sp.append([random.choice(idxs), now])
  245. keep = []
  246. for i, t0 in sp:
  247. age = now - t0
  248. if age < 0.6 and i in idxs:
  249. self.frame[i] = scale((255, 255, 255, 40), 1.0 - age / 0.6)
  250. keep.append([i, t0])
  251. self.sparkles[key] = keep
  252. # ---------- rendering ----------
  253. def segment_color(self, seg):
  254. stype = seg.get("type", "percent")
  255. val = self.statuses.get(seg.get("name", ""), None)
  256. grad = seg.get("gradient") or []
  257. if stype == "percent":
  258. if val is None:
  259. return (4, 4, 4, 0)
  260. return gradient_color(grad, float(val))
  261. if stype == "temp":
  262. # raw Celsius, positioned by the segment's min/max
  263. if val is None:
  264. return (4, 4, 4, 0)
  265. lo = float(seg.get("min_c", 20.0))
  266. hi = float(seg.get("max_c", 90.0))
  267. t = 0.0 if hi <= lo else (float(val) - lo) / (hi - lo)
  268. return gradient_color(grad, t)
  269. if stype == "link":
  270. speed = int(val or 0)
  271. tier = 0
  272. for i, k in enumerate(LINK_TIERS):
  273. if speed >= k and speed > 0:
  274. tier = i
  275. if speed <= 0:
  276. tier = 0
  277. return palette_color(seg, DEFAULT_LINK_COLORS, tier)
  278. if stype == "wifi":
  279. band = int(val or 0)
  280. pos = WIFI_BANDS.index(band) if band in WIFI_BANDS else 0
  281. return palette_color(seg, DEFAULT_WIFI_COLORS, pos)
  282. if stype == "vpn":
  283. on = tuple(pad4(seg.get("on_color", [170, 0, 255, 0])))
  284. off = tuple(pad4(seg.get("off_color", [6, 6, 6, 0])))
  285. return on if val else off
  286. if stype == "bool":
  287. on = tuple(pad4(seg.get("on_color", [0, 255, 0, 0])))
  288. off = tuple(pad4(seg.get("off_color", [4, 0, 0, 0])))
  289. return on if val else off
  290. if stype == "custom":
  291. if isinstance(val, (list, tuple)) and len(val) >= 3:
  292. return tuple(pad4(val))
  293. return tuple(pad4(seg.get("color", [0, 0, 0, 0])))
  294. return (0, 0, 0, 0)
  295. def render_status_segment(self, seg, n):
  296. c = self.segment_color(seg)
  297. start, count = int(seg.get("start", 0)), int(seg.get("count", 1))
  298. fill = seg.get("fill", False) and seg.get("type") == "percent"
  299. val = self.statuses.get(seg.get("name", ""), 0) or 0
  300. for j in range(count):
  301. i = start + j
  302. if not (0 <= i < n):
  303. continue
  304. if fill:
  305. lit = float(val) * count
  306. if j + 1 <= lit:
  307. self.frame[i] = c
  308. elif j < lit:
  309. self.frame[i] = scale(c, lit - j)
  310. else:
  311. self.frame[i] = (2, 2, 2, 0)
  312. else:
  313. self.frame[i] = c
  314. def render_ambient_segment(self, seg, key, now, n):
  315. effect = seg.get("effect", "breathe")
  316. speed = float(seg.get("speed", 1.0))
  317. color = tuple(pad4(seg.get("color", [10, 10, 30, 0])))
  318. start, count = int(seg.get("start", 0)), int(seg.get("count", 1))
  319. idxs = [start + j for j in range(count) if 0 <= start + j < n]
  320. if effect == "off":
  321. for i in idxs:
  322. self.frame[i] = (0, 0, 0, 0)
  323. return
  324. for k, i in enumerate(idxs):
  325. self.frame[i] = self.effect_color(
  326. key, effect, color, speed, now, k, idxs)
  327. if effect == "sparkle":
  328. self.apply_sparkles(key, idxs, speed, now)
  329. def render(self):
  330. now = time.monotonic()
  331. n = self.config["num_pixels"]
  332. if self.raw is not None:
  333. for i in range(n):
  334. self.frame[i] = self.raw[i] if i < len(self.raw) \
  335. else (0, 0, 0, 0)
  336. else:
  337. self.frame = [(0, 0, 0, 0)] * n
  338. for idx, seg in enumerate(self.config.get("segments", [])):
  339. if seg.get("type") == "ambient":
  340. self.render_ambient_segment(seg, "seg%d" % idx, now, n)
  341. else:
  342. self.render_status_segment(seg, n)
  343. # camera glow overlay: assigned LED list + effect preset
  344. if self.camera_active:
  345. cam = self.config["camera"]
  346. color = tuple(pad4(cam.get("color", [255, 40, 0, 0])))
  347. effect = cam.get("effect", "breathe")
  348. speed = float(cam.get("speed", 1.0))
  349. idxs = [i for i in cam.get("pixels", []) if 0 <= i < n]
  350. for k, i in enumerate(idxs):
  351. self.frame[i] = self.effect_color(
  352. "cam", effect, color, speed, now, k, idxs)
  353. if effect == "sparkle":
  354. self.apply_sparkles("cam", idxs, speed, now)
  355. if self.notify:
  356. nt = self.notify
  357. cycle = int((now - nt["t0"]) / nt["period"])
  358. if nt["count"] >= 0 and cycle >= nt["count"] * 2:
  359. self.notify = None
  360. elif cycle % 2 == 0:
  361. nz = self.config["notify"]
  362. s, cnt = int(nz.get("start", 0)), int(nz.get("count", 0))
  363. rng = range(n) if cnt <= 0 else range(s, s + cnt)
  364. for i in rng:
  365. if 0 <= i < n:
  366. self.frame[i] = nt["color"]
  367. if self.test:
  368. if now - self.test["t0"] > TEST_TIMEOUT:
  369. self.test = None
  370. else:
  371. on = int(now * 4) % 2 == 0
  372. c = (255, 140, 0, 0) if on else (255, 255, 255, 60)
  373. s, cnt = self.test["start"], self.test["count"]
  374. for i in range(s, s + cnt):
  375. if 0 <= i < n:
  376. self.frame[i] = c
  377. b = float(self.config.get("brightness", 0.1))
  378. for i in range(n):
  379. c = scale(self.frame[i], b)
  380. self.pixels[i] = c if self.bpp == 4 else c[:3]
  381. self.pixels.show()
  382. def run(self):
  383. next_frame = time.monotonic()
  384. while True:
  385. self.poll_serial()
  386. now = time.monotonic()
  387. if now >= next_frame:
  388. self.render()
  389. next_frame = now + 1.0 / max(
  390. int(self.config.get("fps", 30)), 5)
  391. time.sleep(0.002)
  392. LuxStats().run()