diff --git a/apps/CLUCK/app.toml b/apps/CLUCK/app.toml new file mode 100644 index 0000000..f12e14d --- /dev/null +++ b/apps/CLUCK/app.toml @@ -0,0 +1,8 @@ +[application] +name = "CLUCK" +version = "1.0.0" +description = "Animated farm clock with erratic chickens" +[author] +name = "cleavines01" +[interaction] +requires_user_input = true diff --git a/apps/CLUCK/main.py b/apps/CLUCK/main.py new file mode 100644 index 0000000..e2a843b --- /dev/null +++ b/apps/CLUCK/main.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +""" +CLUCK v1.1.0: A quirky farm clock for NanoKVM. +───────────────────────────────────────────── +Fixes: Clock clamping, goofy zoom chicken, scrollable menu, long-press exit. +""" +import os, sys, time, random, math, threading, mmap +import numpy as np +from PIL import Image, ImageDraw, ImageFont +from input import TouchScreen, GpioKeys, RotaryEncoder + +# ── Display ────────────────────────────────────────────────────────────────── +PW, PH = 172, 320 +LW, LH = 320, 172 +BG_COLOR = (0, 0, 0) + +class FB: + def __init__(self): + sz = PW * PH * 2 + self.fd = os.open('/dev/fb0', os.O_RDWR) + self.mm = mmap.mmap(self.fd, sz, mmap.MAP_SHARED, mmap.PROT_WRITE) + self.arr = np.frombuffer(self.mm, dtype=np.uint16).reshape(PH, PW) + def show(self, img: Image.Image): + p = img.rotate(90, expand=True) + a = np.array(p, dtype=np.uint16) + self.arr[:,:] = (a[:,:,0]>>3<<11)|(a[:,:,1]>>2<<5)|(a[:,:,2]>>3) + def close(self): + self.mm.close(); os.close(self.fd) + +# Colors +WHITE = (255, 255, 255) +RED = (255, 50, 50) +ORANGE = (255, 165, 0) +YELLOW = (255, 215, 0) +PINK = (255, 182, 193) +BROWN = (139, 69, 19) +GREEN = (34, 139, 34) +BLUE = (100, 149, 237) +GRAY = (128, 128, 128) +SKIN = (255, 224, 189) + +_FP = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +_FB = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" +def _f(sz, b=False): + try: return ImageFont.truetype(_FB if b else _FP, sz) + except: return ImageFont.load_default() + +F_CLOCK = _f(48, True) +F_BIG = _f(80, True) +F_MENU = _f(14, True) +F_SM = _f(11) + +# ── Sprites & Drawing ──────────────────────────────────────────────────────── +def draw_chicken(d, x, y, s=1.0, face_right=True, pecking=False, zoom=False): + """Draw a simple chicken at (x,y) with scale s.""" + # Body + w, h = 20*s, 16*s + x0, y0 = x - w//2, y - h + d.ellipse([x0, y0, x0+w, y0+h], fill=WHITE) + + # Head + hw = 10*s + hx = x0+w-4*s if face_right else x0-6*s + hy = y0-6*s + if pecking: hy += 8*s + d.ellipse([hx, hy, hx+hw, hy+hw], fill=WHITE) + + # Beak & Comb + bx = hx+hw if face_right else hx + if face_right: + d.polygon([(bx, hy+4*s), (bx, hy+8*s), (bx+6*s, hy+6*s)], fill=ORANGE) + d.ellipse([bx, hy-4*s, bx+4*s, hy], fill=RED) + else: + d.polygon([(bx, hy+4*s), (bx, hy+8*s), (bx-6*s, hy+6*s)], fill=ORANGE) + d.ellipse([bx-4*s, hy-4*s, bx, hy], fill=RED) + + # Eyes + if zoom: + er = 4*s + d.ellipse([hx+s, hy+s, hx+s+er, hy+s+er], fill=WHITE, outline=BG_COLOR) + d.ellipse([hx+5*s, hy+s, hx+5*s+er, hy+s+er], fill=WHITE, outline=BG_COLOR) + d.ellipse([hx+2*s, hy+2*s, hx+2*s+2*s, hy+2*s+2*s], fill=BG_COLOR) + d.ellipse([hx+6*s, hy+2*s, hx+6*s+2*s, hy+2*s+2*s], fill=BG_COLOR) + else: + ex = hx+6*s if face_right else hx+2*s + d.rectangle([ex, hy+3*s, ex+2*s, hy+5*s], fill=BG_COLOR) + + # Legs + lx = x0 + w//2 + d.line([lx-4*s, y0+h, lx-4*s, y0+h+6*s], fill=ORANGE, width=max(1,int(2*s))) + d.line([lx+4*s, y0+h, lx+4*s, y0+h+6*s], fill=ORANGE, width=max(1,int(2*s))) + +def draw_cow(d, x, y, s=1.0, face_right=True, zoom=False): + w, h = 40*s, 26*s + x0, y0 = x - w//2, y - h + d.rectangle([x0, y0, x0+w, y0+h], fill=WHITE) + d.rectangle([x0+4*s, y0+4*s, x0+12*s, y0+12*s], fill=BG_COLOR) + d.rectangle([x0+24*s, y0+10*s, x0+32*s, y0+20*s], fill=BG_COLOR) + # Head + hx = x0+w-4*s if face_right else x0-10*s + hy = y0-4*s + d.rectangle([hx, hy, hx+14*s, hy+14*s], fill=WHITE) + # Goofy Snout + snw, snh = (20*s if zoom else 14*s), (14*s if zoom else 6*s) + d.rectangle([hx-2*s, hy+8*s, hx+snw, hy+8*s+snh], fill=PINK) + if zoom: + # Giant nostrils + d.ellipse([hx+2*s, hy+10*s, hx+6*s, hy+14*s], fill=BG_COLOR) + d.ellipse([hx+12*s, hy+10*s, hx+16*s, hy+14*s], fill=BG_COLOR) + # Eyelashes + d.line([hx, hy, hx-4*s, hy-4*s], fill=BG_COLOR, width=2) + d.line([hx+4*s, hy, hx+4*s, hy-6*s], fill=BG_COLOR, width=2) + + d.line([x0+4*s, y0+h, x0+4*s, y0+h+8*s], fill=WHITE, width=max(1,int(3*s))) + d.line([x0+w-4*s, y0+h, x0+w-4*s, y0+h+8*s], fill=WHITE, width=max(1,int(3*s))) + +def draw_pig(d, x, y, s=1.0, face_right=True, zoom=False): + w, h = 30*s, 20*s + x0, y0 = x - w//2, y - h + d.ellipse([x0, y0, x0+w, y0+h], fill=PINK) + hx = x0+w-8*s if face_right else x0-6*s + hy = y0+2*s + d.ellipse([hx, hy, hx+14*s, hy+14*s], fill=PINK) + # Massive Snout + sx = hx+10*s if face_right else hx-4*s + snw = 12*s if zoom else 6*s + d.ellipse([sx, hy+4*s, sx+snw, hy+12*s], fill=(255,100,100)) + if zoom: + # Spiral eyes + d.arc([hx+2*s, hy+2*s, hx+6*s, hy+6*s], 0, 360, fill=BG_COLOR) + d.arc([hx+8*s, hy+2*s, hx+12*s, hy+6*s], 0, 360, fill=BG_COLOR) + +def draw_squirrel(d, x, y, s=1.0, face_right=True, zoom=False): + w, h = 16*s, 12*s + x0, y0 = x - w//2, y - h + d.ellipse([x0, y0, x0+w, y0+h], fill=BROWN) + tx = x0-8*s if face_right else x0+w + d.ellipse([tx, y0-8*s, tx+12*s, y0+4*s], fill=BROWN) + if zoom: + # Giant cheeks and teeth + d.ellipse([x-6*s, y-4*s, x+s, y+2*s], fill=PINK) + d.ellipse([x, y-4*s, x+7*s, y+2*s], fill=PINK) + d.rectangle([x-2*s, y, x+3*s, y+6*s], fill=WHITE) # Teeth + +def draw_farmer(d, x, y, s=1.0, is_girl=False, zoom=False): + w, h = 14*s, 24*s + x0, y0 = x - w//2, y - h + color = BLUE if not is_girl else PINK + d.rectangle([x0, y0, x0+w, y0+h], fill=color) + hy = y0 - 10*s + d.ellipse([x0, hy, x0+w, hy+12*s], fill=SKIN) + if not is_girl: + d.rectangle([x0-2*s, hy, x0+w+2*s, hy+4*s], fill=BROWN) + if zoom: + # Huge beard + d.polygon([(x0, hy+8*s), (x0+w, hy+8*s), (x+w//2, hy+20*s)], fill=GRAY) + # Shocked eyes + d.ellipse([x0+2*s, hy+2*s, x0+5*s, hy+5*s], fill=WHITE, outline=BG_COLOR) + d.ellipse([x0+8*s, hy+2*s, x0+11*s, hy+5*s], fill=WHITE, outline=BG_COLOR) + else: + # Pigtails + d.rectangle([x0-4*s, hy, x0, hy+4*s], fill=YELLOW) + d.rectangle([x0+w, hy, x0+w+4*s, hy+4*s], fill=YELLOW) + d.rectangle([x0-2*s, hy, x0+w+2*s, hy+4*s], fill=YELLOW) + if zoom: + # Surprised face + d.ellipse([x0+2*s, hy+6*s, x0+w-2*s, hy+10*s], fill=RED) # O-mouth + +def draw_tractor(d, x, y, s=1.0, face_right=True): + w, h = 40*s, 24*s + x0, y0 = x - w//2, y - h + wx = x0+8*s if face_right else x0+w-8*s + d.ellipse([wx-10*s, y0+h-10*s, wx+10*s, y0+h+10*s], fill=BG_COLOR, outline=RED, width=max(1,int(3*s))) + d.rectangle([x0, y0, x0+w, y0+h], fill=GREEN) + d.rectangle([x0+10*s, y0-10*s, x0+30*s, y0], fill=GREEN) + +def draw_house(d, x, y, s=1.0): + w, h = 60*s, 40*s + x0, y0 = x - w//2, y - h + d.rectangle([x0, y0, x0+w, y0+h], fill=RED) + d.polygon([(x0-4*s, y0), (x0+w//2, y0-20*s), (x0+w+4*s, y0)], fill=BROWN) + d.rectangle([x0+w//2-6*s, y0+h-16*s, x0+w//2+6*s, y0+h], fill=BG_COLOR) + +# ── Logic ──────────────────────────────────────────────────────────────────── +class Entity: + def __init__(self, kind, x, y): + self.kind = kind + self.x, self.y = x, y + self.vx, self.vy = random.uniform(-1, 1), random.uniform(-0.5, 0.5) + self.scale = 1.0 + self.state = "idle" + self.timer = 0 + self.face_right = (self.vx > 0) + self.z = y + + def update(self): + if self.state == "zoom": + self.timer -= 1 + if self.timer <= 0: + self.state = "idle" + self.scale = 1.0 + self.x, self.y = random.randint(20, LW-20), random.randint(40, LH-10) + return + + if random.random() < 0.02: + self.state = random.choice(["idle", "walk", "walk", "peck"]) + self.vx, self.vy = random.uniform(-2, 2), random.uniform(-0.5, 0.5) + + if self.state == "walk": + self.x += self.vx; self.y += self.vy + if self.x < -20: self.x = LW+20 + if self.x > LW+20: self.x = -20 + self.y = max(40, min(LH-10, self.y)) + self.face_right = (self.vx > 0) + + # EVERYONE can zoom! + if self.kind not in ("house", "tractor") and random.random() < 0.0015: + self.state = "zoom"; self.scale = 5.0 + self.x, self.y = LW//2, LH-20; self.timer = 60 + + self.z = self.y + + def draw(self, d): + z = (self.state == "zoom") + if self.kind == "chicken": draw_chicken(d, self.x, self.y, self.scale, self.face_right, self.state=="peck", z) + elif self.kind == "cow": draw_cow(d, self.x, self.y, self.scale, self.face_right, z) + elif self.kind == "pig": draw_pig(d, self.x, self.y, self.scale, self.face_right, z) + elif self.kind == "squirrel": draw_squirrel(d, self.x, self.y, self.scale, self.face_right, z) + elif self.kind == "farmer": draw_farmer(d, self.x, self.y, self.scale, False, z) + elif self.kind == "farmer's daughter": draw_farmer(d, self.x, self.y, self.scale, True, z) + elif self.kind == "tractor": draw_tractor(d, self.x, self.y, self.scale, self.face_right) + elif self.kind == "house": draw_house(d, self.x, self.y, self.scale) + +class App: + def __init__(self): + self.fb = FB() + self.clock_pos = [LW//2, LH//2] + self.clock_scale = 1.0 + self.clock_target_scale = 1.0 + self.entities = [] + self.running = True + self.menu_open = False + self.menu_sel = 0 + self.menu_scroll = 0 + + self.opts = { + "chickens": True, "cows": False, "pigs": False, "squirrels": True, + "farmer": False, "farmer's daughter": False, "tractor": False, "house": False + } + self.opt_keys = list(self.opts.keys()) + ["SAVE & EXIT"] + self._spawn_entities() + + def _spawn_entities(self): + self.entities = [] + for k, v in self.opts.items(): + if v: + if k == "chickens": + for _ in range(3): self.entities.append(Entity("chicken", random.randint(20, LW), random.randint(40, LH))) + else: self.entities.append(Entity(k, random.randint(20, LW), random.randint(40, LH))) + + def update_clock(self): + if random.random() < 0.002: self.clock_target_scale = 2.0 + elif self.clock_scale > 1.1 and random.random() < 0.01: self.clock_target_scale = 1.0 + + for e in self.entities: + if e.kind == "chicken" and e.state == "peck": + dx, dy = self.clock_pos[0]-e.x, self.clock_pos[1]-e.y + if math.hypot(dx, dy) < 40: + self.clock_pos[0] += dx * 0.1; self.clock_pos[1] += dy * 0.1 + + self.clock_scale += (self.clock_target_scale - self.clock_scale) * 0.1 + + def draw(self): + img = Image.new('RGB', (LW, LH), BG_COLOR) + d = ImageDraw.Draw(img) + + self.entities.sort(key=lambda e: e.z) + for e in self.entities: + if not self.menu_open: e.update() + e.draw(d) + + # Clock + t_str = time.strftime("%H:%M") + font = F_BIG if self.clock_scale > 1.5 else F_CLOCK + bb = d.textbbox((0,0), t_str, font=font) + cw, ch = bb[2]-bb[0], bb[3]-bb[1] + cx, cy = (LW//2, LH//2) if self.clock_scale > 1.5 else self.clock_pos + + # STRICT CLAMPING + cx = max(cw//2 + 2, min(LW - cw//2 - 2, cx)) + cy = max(ch//2 + 2, min(LH - ch//2 - 2, cy)) + self.clock_pos = [cx, cy] + + d.text((cx - cw//2, cy - ch//2), t_str, font=font, fill=WHITE) + d.text((LW-20, 4), "@", font=F_MENU, fill=GRAY) # Gear + + if self.menu_open: + d.rectangle([40, 10, LW-40, LH-10], fill=(20, 20, 30), outline=ACCENT) + d.text((LW//2-30, 15), "SETTINGS", font=F_MENU, fill=ACCENT) + start = self.menu_scroll + for i, k in enumerate(self.opt_keys[start:start+6]): + idx = i + start + y = 35 + i*22 + sel = (idx == self.menu_sel) + if k == "SAVE & EXIT": + label = f"{'>' if sel else ' '} {k}" + d.text((50, y), label, font=F_MENU, fill=GREEN if sel else WHITE) + else: + val = self.opts[k] + label = f"{'>' if sel else ' '} {k.upper()}: {'ON' if val else 'OFF'}" + d.text((50, y), label, font=F_SM, fill=ORANGE if sel else WHITE) + self.fb.show(img) + + def run(self): + with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as rotary: + while self.running: + r = rotary.read_event(0) + if r: + if self.menu_open: + self.menu_sel = (self.menu_sel + r) % len(self.opt_keys) + if self.menu_sel < self.menu_scroll: self.menu_scroll = self.menu_sel + elif self.menu_sel >= self.menu_scroll + 6: self.menu_scroll = self.menu_sel - 5 + else: self.clock_pos[0] += r * 8 + + kev = keys.read_event(0) + if kev: + if kev[0] == 'key_long_press': self.running = False + elif kev[0] == 'key_release' and kev[1] == 'ENTER': + if self.menu_open: + k = self.opt_keys[self.menu_sel] + if k == "SAVE & EXIT": self.menu_open = False + else: self.opts[k] = not self.opts[k]; self._spawn_entities() + else: self.menu_open = True + + t = touch.read_event(0) + if t and t[0] == 'touch_down': + tx, ty = TouchScreen.map_coords_270(t[1], t[2]) + if tx > LW-40 and ty < 40: self.menu_open = not self.menu_open + elif self.menu_open and (tx < 40 or tx > LW-40): self.menu_open = False + elif not self.menu_open: self.clock_pos = [tx, ty] + + self.draw() + time.sleep(0.05) + self.fb.close() + +if __name__ == "__main__": + App().run() diff --git a/apps/EQTY/app.toml b/apps/EQTY/app.toml new file mode 100644 index 0000000..f7fde9b --- /dev/null +++ b/apps/EQTY/app.toml @@ -0,0 +1,8 @@ +[application] +name = "EQTY" +version = "1.0.0" +description = "Real-time stock ticker for NanoKVM" +[author] +name = "cleavines01" +[interaction] +requires_user_input = true diff --git a/apps/EQTY/main.py b/apps/EQTY/main.py new file mode 100644 index 0000000..753d9b3 --- /dev/null +++ b/apps/EQTY/main.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +EQTY v1.1.0: Real-time Stock Tracker for NanoKVM. +──────────────────────────────────────────────── +Fixes: Fast numpy FB, standard long-press exit, improved touch. +""" +import os, sys, time, json, math, threading, mmap +import numpy as np +from PIL import Image, ImageDraw, ImageFont +from input import TouchScreen, GpioKeys, RotaryEncoder + +# ── Display ────────────────────────────────────────────────────────────────── +PW, PH = 172, 320 +LW, LH = 320, 172 +BG = (8, 12, 24) + +class FB: + def __init__(self): + sz = PW * PH * 2 + self.fd = os.open('/dev/fb0', os.O_RDWR) + self.mm = mmap.mmap(self.fd, sz, mmap.MAP_SHARED, mmap.PROT_WRITE) + self.arr = np.frombuffer(self.mm, dtype=np.uint16).reshape(PH, PW) + def show(self, img: Image.Image): + p = img.rotate(90, expand=True) + a = np.array(p, dtype=np.uint16) + self.arr[:,:] = (a[:,:,0]>>3<<11)|(a[:,:,1]>>2<<5)|(a[:,:,2]>>3) + def close(self): + self.mm.close(); os.close(self.fd) + +# ── Config & Constants ─────────────────────────────────────────────────────── +CONFIG_FILE = '/etc/kvm/stocks_config.json' +DEFAULT_SYMBOLS = ['PSLV', 'CL=F', 'CRF', 'LEAV', 'INES'] +ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ.=' + +COLOR_WHITE = (230, 230, 230) +COLOR_CYAN = (0, 200, 220) +COLOR_GREEN = (60, 200, 80) +COLOR_RED = (220, 60, 60) +COLOR_GRAY = (120, 120, 140) +COLOR_SEL = (50, 80, 160) +COLOR_DIM = (50, 55, 70) + +_FP = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +_FB = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" +def _f(sz, b=False): + try: return ImageFont.truetype(_FB if b else _FP, sz) + except: return ImageFont.load_default() + +F_SM, F_MD, F_LG = _f(10), _f(12), _f(17, True) + +# ── Utils ──────────────────────────────────────────────────────────────────── +def load_config(): + try: + with open(CONFIG_FILE) as f: return json.load(f).get('symbols', DEFAULT_SYMBOLS) + except: return list(DEFAULT_SYMBOLS) + +def save_config(syms): + try: + os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True) + with open(CONFIG_FILE, 'w') as f: json.dump({'symbols': syms}, f) + except: pass + +def fetch_price(symbol): + try: + import yfinance as yf + t = yf.Ticker(symbol) + d = t.history(period="1d", interval="1m") + if not d.empty: + cur = d['Close'].iloc[-1] + opn = d['Open'].iloc[0] + pct = (cur - opn) / opn * 100 + return cur, pct, d['Close'].tolist() + except: pass + return None, None, None + +# ── App ────────────────────────────────────────────────────────────────────── +class App: + def __init__(self): + self.fb = FB() + self.symbols = load_config() + self.idx = 0 + self.data = {} # sym: (price, pct, history) + self.running = True + self.editing = False + self._dirty = True + self._bg_fetch() + + def _bg_fetch(self): + def _run(): + while self.running: + for s in self.symbols: + p, pct, hist = fetch_price(s) + if p: self.data[s] = (p, pct, hist) + self._dirty = True + time.sleep(60) + threading.Thread(target=_run, daemon=True).start() + + def draw(self): + img = Image.new('RGB', (LW, LH), BG) + d = ImageDraw.Draw(img) + + sym = self.symbols[self.idx] + p, pct, hist = self.data.get(sym, (None, None, None)) + + # Header + d.rectangle([0, 0, LW, 30], fill=(12, 18, 40)) + d.text((8, 5), sym, font=F_LG, fill=COLOR_CYAN) + if p: + col = COLOR_GREEN if pct >= 0 else COLOR_RED + d.text((100, 5), f"${p:.2f}", font=F_LG, fill=WHITE) + d.text((200, 8), f"{pct:+.2f}%", font=F_MD, fill=col) + else: + d.text((100, 8), "Loading...", font=F_MD, fill=COLOR_GRAY) + + # Sparkline + if hist: + ax, ay, aw, ah = 10, 40, LW-20, LH-60 + pmin, pmax = min(hist), max(hist) + prng = (pmax - pmin) or 1 + pts = [] + for i, val in enumerate(hist): + px = ax + (i / len(hist)) * aw + py = ay + ah - ((val - pmin) / prng) * ah + pts.append((px, py)) + d.line(pts, fill=COLOR_CYAN, width=2) + + # Footer + d.rectangle([0, LH-20, LW, LH], fill=(12, 18, 40)) + d.text((8, LH-15), "Rotate: Switch | Long-press: Exit", font=F_SM, fill=COLOR_GRAY) + + self.fb.show(img) + self._dirty = False + + def run(self): + with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as rotary: + while self.running: + r = rotary.read_event(0) + if r: self.idx = (self.idx + r) % len(self.symbols); self._dirty = True + + kev = keys.read_event(0) + if kev: + if kev[0] == 'key_long_press': self.running = False + elif kev[0] == 'key_release' and kev[1] == 'ENTER': + # Simple force refresh + self.data = {}; self._dirty = True + + tev = touch.read_event(0) + if tev and tev[0] == 'touch_down': + self.idx = (self.idx + 1) % len(self.symbols); self._dirty = True + + if self._dirty: self.draw() + time.sleep(0.05) + self.fb.close() + +if __name__ == "__main__": + App().run() diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..dcc77ef --- /dev/null +++ b/apps/README.md @@ -0,0 +1,53 @@ +# NanoKVM UserApp Suite + +This repository contains a collection of high-quality touchscreen applications for the Sipeed NanoKVM Pro. + +## 🚀 Quick Start (Installation) + +To use these apps on your NanoKVM Pro: + +1. **Copy the App Folders:** Transfer the desired app folder (e.g., `CLUCK`, `picoclaw`) to the `/userapp/` directory on your NanoKVM via SCP or SFTP. + ```bash + scp -r ./apps/CLUCK root@:/userapp/ + ``` +2. **Install Dependencies:** Most apps require additional Python libraries. SSH into your NanoKVM and run: + ```bash + apt-get update && apt-get install -y libportaudio2 python3-pyaudio + pip3 install yfinance vosk qrcode flask psutil + ``` +3. **Launch:** The apps will automatically appear in your NanoKVM touchscreen menu under the "UserApp" section. + +--- + +## 📱 Applications + +### 🐔 CLUCK (Farm Clock) +A quirky, interactive clock featuring animated farm life. +- **Features:** High-readability clock, interactive pecking, and unique "goofy zoom" jump-scares for all characters (Cow, Pig, Squirrel, Farmer, etc.). +- **Controls:** Rotate knob to move clock; Press to open settings; Long-press (2s) to exit. + +### 🤖 PicoClaw (AI Agent) +A consolidated AI assistant interface with voice and chat capabilities. +- **Features:** Modern chat bubble UI, offline voice recognition (Vosk), and a QR Login server (port 8080) for easy API key setup. +- **Setup:** Scan the QR code in the "Login" tab to configure your AI provider on your phone. + +### 📈 EQTY (Stock Tickers) +Real-time financial monitoring with high-speed rendering. +- **Features:** Live price updates for symbols like PSLV, CL=F, and CRF. High-performance sparklines. +- **Controls:** Rotate knob to switch symbols. + +### 🛡️ SCRNSVR (Screensaver Manager) +A background daemon and UI to manage device idle states. +- **Features:** Automatically cycles through enabled apps when idle. Includes a live status dashboard. +- **System Service:** Can be run as a systemd service for persistence. + +### 🔗 Tailcode (Tailscale Status) +Quick access to network connectivity information. +- **Features:** Optimized numpy rendering. Displays your Tailscale IP and a login QR code. + +--- + +## 🎮 Global Controls +- **Knob Rotate:** Scroll menus or change values. +- **Knob Press:** Select, confirm, or toggle. +- **Knob Long-Press (2s):** Exit the current app and return to the main system menu. diff --git a/apps/SCRNSVR/app.toml b/apps/SCRNSVR/app.toml new file mode 100644 index 0000000..c62500d --- /dev/null +++ b/apps/SCRNSVR/app.toml @@ -0,0 +1,10 @@ +[application] +name = "Screensaver" +version = "1.0.0" +descriptions = "Screensaver manager — cycle apps on idle, set timeouts, enable/disable per-app." + +[author] +name = "cleavines01" + +[interaction] +requires_user_input = true diff --git a/apps/SCRNSVR/daemon.py b/apps/SCRNSVR/daemon.py new file mode 100644 index 0000000..c7c7042 --- /dev/null +++ b/apps/SCRNSVR/daemon.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +""" +Screensaver daemon for NanoKVM Pro. +Monitors input idle time and cycles through enabled apps on the screen. + +Usage: + python3 daemon.py # foreground (debug) + python3 daemon.py --daemon # daemonize + python3 daemon.py --stop # stop running daemon + python3 daemon.py --status # print status +""" +import os, sys, time, json, signal, struct, select, subprocess, random, mmap + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +CONFIG_FILE = "/etc/kvm/screensaver.json" +DAEMON_PID = "/tmp/screensaver_daemon.pid" +PLAYING_PID = "/tmp/screensaver_playing.pid" +STATUS_FILE = "/tmp/screensaver_status.json" +LOG_FILE = "/tmp/screensaver.log" +USERAPP_DIR = "/userapp" +INPUT_DEVS = ['/dev/input/event0', '/dev/input/event1', '/dev/input/event2'] + +EV_FMT = 'llHHi' +EV_SZ = struct.calcsize(EV_FMT) + +SKIP_APPS = {'screensaver', 'readme.md'} + + +def default_cfg(): + return { + "enabled": True, + "idle_timeout": 300, + "cycle_interval": 60, + "order": "cycle", + "blackout": False, + "apps": {} + } + + +def load_cfg(): + try: + with open(CONFIG_FILE) as f: + cfg = json.load(f) + for k, v in default_cfg().items(): + cfg.setdefault(k, v) + return cfg + except: + return default_cfg() + + +def all_apps(): + apps = [] + try: + for name in sorted(os.listdir(USERAPP_DIR)): + path = os.path.join(USERAPP_DIR, name) + if (os.path.isdir(path) + and os.path.exists(os.path.join(path, 'main.py')) + and name not in SKIP_APPS): + apps.append(name) + except: + pass + return apps + + +def enabled_apps(cfg): + app_cfg = cfg.get("apps", {}) + return [a for a in all_apps() if app_cfg.get(a, False)] + + +def any_userapp_running(): + """Return True if a non-screensaver userapp main.py is running.""" + try: + out = subprocess.check_output( + ['pgrep', '-f', r'userapp/.*/main\.py'], text=True + ).strip() + # Exclude our own screensaver playing process + pids = [int(p) for p in out.split() if p] + playing = _read_pid(PLAYING_PID) + return any(p != playing for p in pids) + except: + return False + + +def _read_pid(path): + try: + with open(path) as f: + return int(f.read().strip()) + except: + return None + + +def kill_playing(): + pid = _read_pid(PLAYING_PID) + if pid: + try: + os.kill(pid, signal.SIGTERM) + except: + pass + try: + os.remove(PLAYING_PID) + except: + pass + # Also kill any orphaned screensaver python3 processes + try: + subprocess.run(['pkill', '-f', r'screensaver.*blackout'], capture_output=True) + except: + pass + + +def launch_app(name): + kill_playing() + app_dir = os.path.join(USERAPP_DIR, name) + try: + proc = subprocess.Popen( + ['python3', 'main.py'], + cwd=app_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + with open(PLAYING_PID, 'w') as f: + f.write(str(proc.pid)) + return proc + except Exception as e: + log(f"Failed to launch {name}: {e}") + return None + + +def launch_blackout(): + kill_playing() + script = ( + 'import mmap,os,numpy as np;' + 'fd=os.open("/dev/fb0",os.O_RDWR);' + 'sz=172*320*2;mm=mmap.mmap(fd,sz,mmap.MAP_SHARED,mmap.PROT_WRITE);' + 'arr=np.frombuffer(mm,dtype="uint16").reshape(320,172);' + 'arr[:,:]=0;mm.close();os.close(fd);' + 'import time;time.sleep(86400)' + ) + try: + proc = subprocess.Popen( + ['python3', '-c', script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + with open(PLAYING_PID, 'w') as f: + f.write(str(proc.pid)) + return proc + except: + return None + + +def write_status(state, current=None, idle=0, next_switch=None): + try: + with open(STATUS_FILE, 'w') as f: + json.dump({ + "state": state, + "current": current, + "idle_seconds": int(idle), + "next_switch_in": int(next_switch) if next_switch else None, + "timestamp": time.time() + }, f) + except: + pass + + +def log(msg): + try: + print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True) + except: + pass + + +def open_inputs(): + fds = [] + for path in INPUT_DEVS: + try: + fds.append(os.open(path, os.O_RDONLY | os.O_NONBLOCK)) + except: + pass + return fds + + +def drain_input(fds, timeout=0.15): + """Returns True if any input event was seen.""" + if not fds: + time.sleep(timeout) + return False + ready, _, _ = select.select(fds, [], [], timeout) + for fd in ready: + try: + os.read(fd, EV_SZ * 16) + except: + pass + return bool(ready) + + +def run(): + with open(DAEMON_PID, 'w') as f: + f.write(str(os.getpid())) + + log(f"Screensaver daemon started (PID {os.getpid()})") + fds = open_inputs() + + last_activity = time.time() + last_cfg_mtime = 0 + cfg = load_cfg() + app_index = 0 + current_proc = None + current_name = None + started_at = None + + def on_activity(): + nonlocal last_activity, current_proc, current_name, started_at + last_activity = time.time() + if current_proc is not None: + log(f"Input detected — stopping screensaver ({current_name})") + kill_playing() + current_proc = None + current_name = None + started_at = None + + try: + while True: + # Reload config if changed + try: + mt = os.path.getmtime(CONFIG_FILE) + if mt != last_cfg_mtime: + cfg = load_cfg() + last_cfg_mtime = mt + log("Config reloaded") + except: + pass + + # Check input + if drain_input(fds): + on_activity() + continue + + now = time.time() + idle = now - last_activity + + # If a user is actively in an app, stay idle + if any_userapp_running(): + last_activity = now + write_status("user_active", idle=0) + continue + + if not cfg.get("enabled", True): + write_status("disabled", idle=idle) + continue + + # Check if current proc died naturally + if current_proc is not None and current_proc.poll() is not None: + log(f"Screensaver {current_name} exited naturally") + current_proc = None + current_name = None + started_at = None + + # Cycle to next app if interval elapsed + if current_proc is not None: + elapsed = now - started_at + remaining = cfg.get("cycle_interval", 60) - elapsed + write_status("playing", current_name, idle, remaining) + if elapsed >= cfg.get("cycle_interval", 60): + log(f"Cycle interval elapsed — switching from {current_name}") + kill_playing() + current_proc = None + current_name = None + started_at = None + continue + + # Start screensaver if idle threshold reached + if idle >= cfg.get("idle_timeout", 300): + apps = enabled_apps(cfg) + pool = apps + (["__blackout__"] if cfg.get("blackout") else []) + + if not pool: + write_status("idle_no_apps", idle=idle) + time.sleep(2) + continue + + if cfg.get("order", "cycle") == "random": + choice = random.choice(pool) + else: + app_index = app_index % len(pool) + choice = pool[app_index] + app_index += 1 + + log(f"Idle {int(idle)}s — launching: {choice}") + current_proc = launch_blackout() if choice == "__blackout__" else launch_app(choice) + current_name = choice + started_at = time.time() + else: + remaining_idle = cfg.get("idle_timeout", 300) - idle + write_status("waiting", idle=idle, next_switch=remaining_idle) + + except KeyboardInterrupt: + pass + finally: + kill_playing() + for fd in fds: + try: + os.close(fd) + except: + pass + for f in [DAEMON_PID, STATUS_FILE]: + try: + os.remove(f) + except: + pass + log("Screensaver daemon stopped") + + +def daemonize(): + if os.fork() > 0: + sys.exit(0) + os.setsid() + if os.fork() > 0: + sys.exit(0) + sys.stdin = open('/dev/null', 'r') + sys.stdout = open(LOG_FILE, 'a') + sys.stderr = sys.stdout + + +if __name__ == '__main__': + args = sys.argv[1:] + + if '--stop' in args: + pid = _read_pid(DAEMON_PID) + if pid: + try: + os.kill(pid, signal.SIGTERM) + print(f"Stopped daemon (PID {pid})") + except: + print("Daemon not running") + else: + print("No daemon PID found") + kill_playing() + sys.exit(0) + + if '--status' in args: + try: + with open(STATUS_FILE) as f: + print(json.dumps(json.load(f), indent=2)) + except: + print("Daemon not running") + sys.exit(0) + + if '--daemon' in args: + daemonize() + + run() diff --git a/apps/SCRNSVR/main.py b/apps/SCRNSVR/main.py new file mode 100644 index 0000000..a1166e7 --- /dev/null +++ b/apps/SCRNSVR/main.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Screensaver Manager UI +─────────────────────── +Manage idle timeouts, cycling, and app selection. +""" +import os, sys, time, json, threading +import numpy as np +from PIL import Image, ImageDraw, ImageFont +from input import TouchScreen, GpioKeys, RotaryEncoder + +# ── Display ────────────────────────────────────────────────────────────────── +PW, PH = 172, 320 +LW, LH = 320, 172 +BG = (10, 10, 15) +PANEL = (25, 25, 35) +ACCENT = (0, 200, 255) +TEXT = (240, 240, 240) +DIM = (100, 100, 110) +SEL = (40, 70, 140) +OK = (0, 210, 110) + +_FP = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +_FB = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" +def _f(sz, b=False): + try: return ImageFont.truetype(_FB if b else _FP, sz) + except: return ImageFont.load_default() + +FN, FS, FM = _f(10), _f(12), _f(14) +FT, FH = _f(16, True), _f(20, True) + +class FB: + def __init__(self): + import mmap + self.fd = os.open('/dev/fb0', os.O_RDWR) + self.mm = mmap.mmap(self.fd, PW*PH*2, mmap.MAP_SHARED, mmap.PROT_WRITE) + self.arr = np.frombuffer(self.mm, dtype=np.uint16).reshape(PH, PW) + def show(self, img): + p = img.rotate(90, expand=True) + a = np.array(p, dtype=np.uint16) + self.arr[:,:] = (a[:,:,0]>>3<<11)|(a[:,:,1]>>2<<5)|(a[:,:,2]>>3) + def close(self): + self.mm.close(); os.close(self.fd) + +def rrect(d, x0, y0, x1, y1, fill=None, outline=None, r=4): + d.rounded_rectangle([x0, y0, x1, y1], radius=r, fill=fill, outline=outline) + +def centered(d, text, cx, cy, font, color): + bb = d.textbbox((0, 0), text, font=font) + d.text((cx - (bb[2]-bb[0])//2, cy - (bb[3]-bb[1])//2), text, font=font, fill=color) + +# ── Config ─────────────────────────────────────────────────────────────────── +CFG_PATH = "/etc/kvm/screensaver.json" +STATUS_PATH = "/tmp/screensaver_status.json" + +def load_cfg(): + try: + with open(CFG_PATH) as f: return json.load(f) + except: return {"enabled": True, "idle_timeout": 60, "cycle_interval": 30, "order": "cycle", "apps": {}} + +def save_cfg(c): + os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) + with open(CFG_PATH, 'w') as f: json.dump(c, f, indent=2) + +def get_status(): + try: + with open(STATUS_PATH) as f: return json.load(f) + except: return {} + +# ── App ────────────────────────────────────────────────────────────────────── +class App: + def __init__(self): + self.fb = FB() + self.cfg = load_cfg() + self.page = 0 # 0=Apps, 1=Timers, 2=Options, 3=Status + self.sel = 0 + self.running = True + self.dirty = True + self.apps = sorted([d for d in os.listdir("/userapp") if os.path.isdir(os.path.join("/userapp", d)) and d not in ('screensaver','readme.md')]) + + def on_rotate(self, delta): + if self.page == 0: self.sel = (self.sel + delta) % len(self.apps) + elif self.page == 1: self.sel = (self.sel + delta) % 2 + elif self.page == 2: self.sel = (self.sel + delta) % 3 + self.dirty = True + + def on_press(self): + if self.page == 0: + app = self.apps[self.sel] + cur = self.cfg['apps'].get(app, True) + self.cfg['apps'][app] = not cur + save_cfg(self.cfg) + elif self.page == 1: + if self.sel == 0: # timeout + opts = [30, 60, 120, 300, 600, 0] + idx = opts.index(self.cfg['idle_timeout']) + self.cfg['idle_timeout'] = opts[(idx+1)%len(opts)] + else: # cycle + opts = [10, 30, 60, 120, 300] + idx = opts.index(self.cfg['cycle_interval']) + self.cfg['cycle_interval'] = opts[(idx+1)%len(opts)] + save_cfg(self.cfg) + elif self.page == 2: + if self.sel == 0: self.cfg['enabled'] = not self.cfg['enabled'] + elif self.sel == 1: self.cfg['order'] = "random" if self.cfg['order'] == "cycle" else "cycle" + elif self.sel == 2: # Restart daemon + os.system("systemctl restart screensaver") + save_cfg(self.cfg) + self.dirty = True + + def draw(self): + img = Image.new('RGB', (LW, LH), BG) + d = ImageDraw.Draw(img) + # Tabs + tabs = ["Apps", "Time", "Opt", "Stat"] + tw = LW // 4 + for i, t in enumerate(tabs): + rrect(d, i*tw+2, 2, (i+1)*tw-2, 22, fill=SEL if self.page == i else PANEL) + centered(d, t, i*tw+tw//2, 12, FS, TEXT) + + y0 = 28 + if self.page == 0: # Apps list + start = max(0, self.sel - 2) + for i, app in enumerate(self.apps[start:start+5]): + idx = i + start + y = y0 + i*24 + is_sel = (idx == self.sel) + if is_sel: d.rectangle([4, y, LW-4, y+22], fill=SEL) + enabled = self.cfg['apps'].get(app, True) + d.text((10, y+4), f"[{'X' if enabled else ' '}] {app}", font=FM, fill=TEXT) + + elif self.page == 1: # Timers + T = ["Idle Timeout", "Cycle Every"] + V = [f"{self.cfg['idle_timeout']}s" if self.cfg['idle_timeout'] else "Never", f"{self.cfg['cycle_interval']}s"] + for i in range(2): + y = y0 + i*40 + rrect(d, 8, y, LW-8, y+34, fill=SEL if self.sel == i else PANEL) + d.text((16, y+8), T[i], font=FM, fill=TEXT) + d.text((LW-70, y+8), V[i], font=FM, fill=ACCENT) + + elif self.page == 2: # Options + opts = [("Enabled", self.cfg['enabled']), ("Order", self.cfg['order']), ("Action", "RESTART")] + for i, (l, v) in enumerate(opts): + y = y0 + i*36 + rrect(d, 8, y, LW-8, y+30, fill=SEL if self.sel == i else PANEL) + d.text((16, y+6), l, font=FM, fill=TEXT) + d.text((LW-100, y+6), str(v).upper(), font=FM, fill=ACCENT) + + elif self.page == 3: # Status + st = get_status() + lines = [ + f"Daemon: {'Running' if st else 'Stopped'}", + f"Current: {st.get('current', 'None')}", + f"Idle: {st.get('idle_seconds', 0)}s", + f"Next Switch: {st.get('next_switch_in', 0)}s" + ] + for i, l in enumerate(lines): + d.text((12, y0 + i*22), l, font=FM, fill=OK if i==0 else TEXT) + + self.fb.show(img) + self.dirty = False + + def run(self): + with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as rotary: + while self.running: + r = rotary.read_event(0) + if r: self.on_rotate(r) + k = keys.read_event(0) + if k and k[0] == 'key_release' and k[1] == 'ENTER': self.on_press() + elif k and k[0] == 'key_long_press': self.running = False + t = touch.read_event(0) + if t and t[0] == 'touch_down': + x, y = TouchScreen.map_coords_270(t[1], t[2]) + if y < 24: self.page = x // (LW//4); self.sel = 0; self.dirty = True + if self.dirty: self.draw() + time.sleep(0.05) + +if __name__ == "__main__": + App().run() diff --git a/apps/picoclaw/app.toml b/apps/picoclaw/app.toml new file mode 100644 index 0000000..954e1c6 --- /dev/null +++ b/apps/picoclaw/app.toml @@ -0,0 +1,10 @@ +[application] +name = "PicoClaw" +version = "1.0.0" +descriptions = "PicoClaw AI Agent — voice commands, agent chat, skills, auth, and configuration." + +[author] +name = "Claude" + +[interaction] +requires_user_input = true diff --git a/apps/picoclaw/main.py b/apps/picoclaw/main.py new file mode 100644 index 0000000..eaec957 --- /dev/null +++ b/apps/picoclaw/main.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +""" +PicoClaw NanoKVM Screen App v1.1.0 +──────────────────────────────────── +Consolidated Agent/Voice experience + QR Login. +""" + +import os, sys, time, json, threading, mmap, textwrap, socket +import numpy as np +from PIL import Image, ImageDraw, ImageFont, ImageOps +import qrcode + +import picoclaw_cli as pc +import voice as vc +from input import TouchScreen, GpioKeys, RotaryEncoder + +# ── Display ────────────────────────────────────────────────────────────────── +PW, PH = 172, 320 +LW, LH = 320, 172 + +BG = (8, 8, 22) +PANEL = (18, 20, 46) +ACCENT = (0, 185, 255) +TEXT = (230, 235, 255) +DIM = (90, 95, 130) +OK = (0, 210, 110) +ERR = (255, 70, 70) +WARN = (255, 195, 0) +SEL = (30, 60, 135) +BTN = (35, 38, 82) +BTNH = (55, 100, 205) +REC = (210, 30, 30) +AI_MSG = (30, 35, 70) +USER_MSG = (0, 95, 140) + +_FP = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +_FB = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + +def _f(sz, bold=False): + try: return ImageFont.truetype(_FB if bold else _FP, sz) + except: return ImageFont.load_default() + +FN = _f(9); FS = _f(11); FM = _f(13); FL = _f(16) +FT = _f(15, True); FH = _f(18, True) + +def get_sys_stats(): + try: + import psutil + cpu = f"{psutil.cpu_percent()}%" + mem = f"{psutil.virtual_memory().percent}%" + try: + with open('/sys/class/thermal/thermal_zone0/temp') as f: + temp = f"{int(f.read()) // 1000}°C" + except: temp = "?" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0]; s.close() + except: ip = "127.0.0.1" + return {"cpu": cpu, "mem": mem, "temp": temp, "ip": ip} + except: return {"cpu": "?", "mem": "?", "temp": "?", "ip": "?"} + +class FB: + def __init__(self): + sz = PW * PH * 2 + self.fd = os.open('/dev/fb0', os.O_RDWR) + self.mm = mmap.mmap(self.fd, sz, mmap.MAP_SHARED, mmap.PROT_WRITE) + self.arr = np.frombuffer(self.mm, dtype=np.uint16).reshape(PH, PW) + def show(self, img: Image.Image): + p = img.rotate(90, expand=True) + a = np.array(p, dtype=np.uint16) + self.arr[:, :] = (a[:, :, 0] >> 3 << 11) | (a[:, :, 1] >> 2 << 5) | (a[:, :, 2] >> 3) + def close(self): + self.mm.close(); os.close(self.fd) + +# ── UI Helpers ──────────────────────────────────────────────────────────────── +def rrect(d, x0, y0, x1, y1, fill=None, outline=None, r=4): + d.rounded_rectangle([x0, y0, x1, y1], radius=r, fill=fill, outline=outline) + +def centered(d, text, cx, cy, font, color): + bb = d.textbbox((0, 0), text, font=font) + d.text((cx - (bb[2]-bb[0])//2, cy - (bb[3]-bb[1])//2), text, font=font, fill=color) + +def topbar(d, title, dot=OK): + d.rectangle([0, 0, LW, 21], fill=PANEL) + d.text((8, 3), title, font=FT, fill=ACCENT) + d.ellipse([LW-13, 6, LW-5, 14], fill=dot) + d.line([0, 21, LW, 21], fill=DIM, width=1) + +def wordwrap(text, maxw, d, font): + words = text.split() + lines, cur = [], "" + for w in words: + t = (cur + " " + w).strip() + bb = d.textbbox((0, 0), t, font=font) + if bb[2]-bb[0] > maxw and cur: + lines.append(cur); cur = w + else: cur = t + if cur: lines.append(cur) + return lines + +def drawbubble(d, y, role, text, font=FN): + is_user = (role == 'you') + color = USER_MSG if is_user else AI_MSG + max_w = LW - 60 + lines = wordwrap(text, max_w - 20, d, font) + if not lines: return y + lh = font.getbbox("A")[3] + 2 + bh = len(lines) * lh + 12 + bw = max([d.textbbox((0, 0), l, font=font)[2] for l in lines]) + 16 + x0 = LW - bw - 8 if is_user else 8 + x1 = x0 + bw + rrect(d, x0, y, x1, y + bh, fill=color, r=8) + ty = y + 6 + for line in lines: + d.text((x0 + 8, ty), line, font=font, fill=TEXT) + ty += lh + return y + bh + 6 + +def drawwaveform(d, x, y, w, h, level): + v = min(h, int(level / 3000 * h)) if level > 0 else 2 + rrect(d, x, y + h//2 - v//2, x + w, y + h//2 + v//2, fill=ACCENT if level > 500 else DIM, r=2) + +# ── Pages ────────────────────────────────────────────────────────────────── +PG_HOME, PG_AGENT, PG_SKILLS, PG_AUTH, PG_SETUP, PG_STATUS = range(6) +HOME_TARGETS = [PG_AGENT, PG_SKILLS, PG_AUTH, PG_SETUP, PG_STATUS] +HOME_LABELS = ["Agent Chat", "Skills", "Login / Auth", "Setup", "Status"] +HOME_ICONS = [">_ ", "## ", "[K]", "{} ", " i "] + +class App: + def __init__(self): + self.fb = FB() + self.rec = vc.Recorder() + self.cfg = {"profile": "claude"} + self._cache_lock = threading.Lock() + self._cache = {} + self._dirty = True + self.running = True + + self.page = PG_HOME + self.home_sel = 0 + + self.agent_hist = [] + self.agent_scroll = 0 + self.agent_busy = False + self.agent_msg = "" + self.voice_state = "idle" + self.voice_text = "" + + self.setup_sel = 0 + self.setup_scroll = 0 + self.stats = {} + + self.skills = [] + self.skills_sel = 0 + + self.auth_qr = None + self.auth_busy = False + + threading.Thread(target=self._stats_loop, daemon=True).start() + self._bg('auth', pc.auth_status) + self._bg('version', pc.get_version) + + def _stats_loop(self): + while self.running: + self.stats = get_sys_stats() + if self.page in (PG_HOME, PG_STATUS, PG_SETUP): self._dirty = True + time.sleep(2) + + def _bg(self, key, fn, *args): + def _run(): + try: + r = fn(*args) + with self._cache_lock: self._cache[key] = ('ok', r) + except Exception as e: + with self._cache_lock: self._cache[key] = ('err', str(e)) + self._dirty = True + threading.Thread(target=_run, daemon=True).start() + + def _get(self, key): + with self._cache_lock: return self._cache.get(key) + + def goto(self, page): + self.page = page + self._dirty = True + if page == PG_SKILLS: self._bg('skills', pc.list_skills) + elif page == PG_AUTH: self._gen_auth_qr() + + def _gen_auth_qr(self): + ip = self.stats.get('ip', '127.0.0.1') + url = f"http://{ip}/picoclaw_login" # TBD: Local server placeholder + qr = qrcode.QRCode(box_size=3, border=1) + qr.add_data(url) + qr.make(fit=True) + self.auth_qr = qr.make_image(fill_color="black", back_color="white").convert("RGB") + self._dirty = True + + def on_rotate(self, delta): + p = self.page + if p == PG_HOME: self.home_sel = (self.home_sel + delta) % len(HOME_TARGETS) + elif p == PG_AGENT: self.agent_scroll = max(0, self.agent_scroll + delta) + elif p == PG_SETUP: self.setup_sel = (self.setup_sel + delta) % 8 + elif p == PG_SKILLS: self.skills_sel = max(0, min(len(self.skills)-1, self.skills_sel + delta)) + self._dirty = True + + def on_press(self): + p = self.page + if p == PG_HOME: self.goto(HOME_TARGETS[self.home_sel]) + elif p == PG_AGENT: self._toggle_voice() + elif p == PG_AUTH: self._bg('auth', pc.auth_status) + elif p == PG_SETUP: self._setup_action(self.setup_sel) + self._dirty = True + + def _toggle_voice(self): + if self.voice_state in ('idle', 'done', 'error'): + if self.rec.start(): + self.voice_state = 'recording' + threading.Thread(target=self._auto_stop, daemon=True).start() + else: self.voice_state = 'error' + elif self.voice_state == 'recording': + self._stop_recording() + self._dirty = True + + def _auto_stop(self): + time.sleep(vc.MAX_SECS) + if self.voice_state == 'recording': self._stop_recording() + + def _stop_recording(self): + self.voice_state = 'processing' + self._dirty = True + audio = self.rec.stop() + def _trans(): + text, err = vc.transcribe(audio) + if not err and text: + self.voice_text = text + self._agent_send(text) + self.voice_state = 'idle'; self._dirty = True + threading.Thread(target=_trans, daemon=True).start() + + def _agent_send(self, message): + if self.agent_busy: return + self.agent_busy = True + self.agent_hist.append(('you', message)) + self._dirty = True + def _run(): + reply, ok = pc.agent_message(message) + self.agent_hist.append(('ai', reply[:400])) + self.agent_busy = False; self._dirty = True + threading.Thread(target=_run, daemon=True).start() + + def _setup_action(self, sel): + if sel == 7: self.goto(PG_HOME) + + def draw(self): + img = Image.new('RGB', (LW, LH), BG) + d = ImageDraw.Draw(img) + p = self.page + if p == PG_HOME: self._draw_home(d) + elif p == PG_AGENT: self._draw_agent(d) + elif p == PG_SKILLS: self._draw_skills(d) + elif p == PG_AUTH: self._draw_auth(d) + elif p == PG_SETUP: self._draw_setup(d) + elif p == PG_STATUS: self._draw_status(d) + self.fb.show(img) + self._dirty = False + + def _draw_home(self, d): + ver = self._get('version')[1] if self._get('version') else "..." + topbar(d, f"PicoClaw v{ver}") + bw, bh = LW // 3, (LH - 44) // 2 + for i, (lbl, ico) in enumerate(zip(HOME_LABELS, HOME_ICONS)): + col, row = i % 3, i // 3 + x0, y0 = col*bw+4, 24+row*bh+4 + x1, y1 = x0+bw-8, y0+bh-8 + sel = (i == self.home_sel) + rrect(d, x0, y0, x1, y1, fill=SEL if sel else BTN, outline=ACCENT if sel else None) + d.text((x0+8, y0+6), ico, font=FN, fill=ACCENT if sel else DIM) + centered(d, lbl, (x0+x1)//2, (y0+y1)//2+6, FM, TEXT if sel else DIM) + s = self.stats + stat_str = f"CPU:{s.get('cpu')} RAM:{s.get('mem')} IP:{s.get('ip')}" + d.text((8, LH-15), stat_str, font=FN, fill=DIM) + + def _draw_agent(self, d): + topbar(d, "Agent Chat") + y = 26 + for role, text in self.agent_hist[max(0, len(self.agent_hist)-3):]: + y = drawbubble(d, y, role, text) + d.rectangle([0, 143, LW, LH], fill=PANEL) + if self.voice_state == 'recording': + drawwaveform(d, 8, 150, 240, 14, self.rec.level) + d.text((22, 160), "Listening...", font=FN, fill=ACCENT) + elif self.agent_busy: + d.text((8, 153), "Thinking...", font=FN, fill=WARN) + else: + d.text((8, 153), "Press knob to speak", font=FN, fill=DIM) + rrect(d, LW-48, 145, LW-4, LH-4, fill=REC if self.voice_state=='recording' else BTNH) + centered(d, "MIC", LW-26, 157, FM, TEXT) + + def _draw_skills(self, d): + topbar(d, "Skills") + r = self._get('skills') + if r and r[0]=='ok': + self.skills = r[1] + for i, sk in enumerate(self.skills[:5]): + y = 26 + i*24 + sel = (i == self.skills_sel) + if sel: rrect(d, 4, y, LW-4, y+22, fill=SEL) + d.text((12, y+5), sk[:40], font=FS, fill=TEXT if sel else DIM) + else: centered(d, "Loading skills...", LW//2, LH//2, FM, DIM) + + def _draw_auth(self, d): + topbar(d, "Authentication") + if self.auth_qr: + img_qr = self.auth_qr.resize((100, 100)) + d.bitmap((LW//2-50, 30), img_qr) + centered(d, "Scan QR to Login", LW//2, 140, FS, TEXT) + centered(d, self.stats.get('ip',''), LW//2, 155, FN, DIM) + + def _draw_setup(self, d): + topbar(d, "Setup") + items = ["Mic Check", "Profile", "Model", "Onboard", "Gateway", "Clean", "Debug", "Back"] + start = max(0, min(self.setup_sel-2, 4)) + for i, item in enumerate(items[start:start+5]): + idx = i + start + y = 26 + i*24 + sel = (idx == self.setup_sel) + if sel: rrect(d, 4, y, LW-4, y+22, fill=SEL) + d.text((12, y+5), item, font=FS, fill=TEXT if sel else DIM) + + def _draw_status(self, d): + topbar(d, "System Status") + y = 28 + for k, v in self.stats.items(): + d.text((10, y), f"{k.upper()}: {v}", font=FM, fill=TEXT) + y += 20 + + def run(self): + with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as rotary: + while self.running: + r = rotary.read_event(0) + if r: self.on_rotate(r) + k = keys.read_event(0) + if k and k[0]=='key_release': self.on_press() + elif k and k[0]=='key_long_press': self.running=False + t = touch.read_event(0) + if t and t[0]=='touch_down': + tx, ty = TouchScreen.map_coords_270(t[1], t[2]) + if tx < 50 and ty < 30: self.goto(PG_HOME) + if self._dirty: self.draw() + time.sleep(0.05) + self.fb.close(); self.rec.close() + +if __name__ == "__main__": + App().run() diff --git a/apps/picoclaw/picoclaw_cli.py b/apps/picoclaw/picoclaw_cli.py new file mode 100644 index 0000000..5475418 --- /dev/null +++ b/apps/picoclaw/picoclaw_cli.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""PicoClaw CLI wrapper for NanoKVM screen app.""" +import subprocess +import os +import json + +BIN = "/usr/bin/picoclaw" +PROFILES_DIR = "/dev/shm/kvmapp/cua/profiles" + + +def _run(args, timeout=30): + try: + r = subprocess.run( + [BIN] + args, + capture_output=True, text=True, timeout=timeout + ) + return r.stdout.strip(), r.stderr.strip(), r.returncode + except subprocess.TimeoutExpired: + return "", "Timeout", -1 + except Exception as e: + return "", str(e), -1 + + +def get_version(): + out, _, _ = _run(["version"]) + for part in out.split(): + if part and part[0].isdigit(): + return part + return out or "?" + + +def get_status(): + out, err, code = _run(["status"]) + return out if out else err + + +def auth_status(): + out, err, code = _run(["auth", "status"]) + return out if code == 0 else (err or "Unknown") + + +def auth_login(): + out, err, code = _run(["auth", "login"], timeout=60) + return code == 0, (out or err or "Done") + + +def auth_logout(): + out, err, code = _run(["auth", "logout"]) + return code == 0, (out or err or "Done") + + +def list_skills(): + out, err, code = _run(["skills"]) + if code != 0: + return [] + lines = [] + for line in out.split('\n'): + line = line.strip() + if line and not line.startswith('#') and not line.startswith('=') and not line.startswith('NAME'): + lines.append(line) + return lines + + +def agent_message(message, model=None): + args = ["agent", "-m", message] + if model: + args += ["--model", model] + out, err, code = _run(args, timeout=90) + return (out or err), code == 0 + + +def run_onboard(): + out, err, code = _run(["onboard"], timeout=120) + return code == 0, (out or err or "Done") + + +def start_gateway(): + try: + subprocess.Popen( + [BIN, "gateway"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + return True, "Gateway started" + except Exception as e: + return False, str(e) + + +def list_profiles(): + try: + return [f.replace('.json', '') for f in os.listdir(PROFILES_DIR) if f.endswith('.json')] + except: + return ["claude", "gemini"] + + +def get_profile_model(profile): + try: + path = os.path.join(PROFILES_DIR, f"{profile}.json") + with open(path) as f: + return json.load(f).get("model_name", "") + except: + return "" diff --git a/apps/picoclaw/voice.py b/apps/picoclaw/voice.py new file mode 100644 index 0000000..c6cbb7b --- /dev/null +++ b/apps/picoclaw/voice.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Local speech recognition using Vosk (offline, no API key needed). +Model: vosk-model-small-en-us-0.15 (~40MB, stored in ./model/) +Falls back gracefully if model not loaded yet. +""" +import pyaudio +import wave +import io +import json +import threading +import os + +RATE = 16000 +CHANNELS = 1 +FORMAT = pyaudio.paInt16 +CHUNK = 4000 # Vosk prefers larger chunks +MAX_SECS = 10 + +MODEL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "model") + +# Load Vosk model once at import time (non-blocking — done in background thread) +_model = None +_model_lock = threading.Lock() +_model_ready = threading.Event() + +def _load_model(): + global _model + try: + from vosk import Model, SetLogLevel + SetLogLevel(-1) # suppress verbose output + with _model_lock: + _model = Model(MODEL_PATH) + _model_ready.set() + print("Vosk model loaded") + except Exception as e: + print(f"Vosk model load error: {e}") + _model_ready.set() # unblock waiter even on failure + +# Start loading immediately when app starts +threading.Thread(target=_load_model, daemon=True).start() + + +class Recorder: + def __init__(self): + self.pa = pyaudio.PyAudio() + self.recording = False + self.frames = [] + self._stream = None + self._thread = None + self.error = None + self.device_index = self._find_input() + self._last_level = 0 + + def _find_input(self): + for i in range(self.pa.get_device_count()): + if self.pa.get_device_info_by_index(i)['maxInputChannels'] > 0: + return i + return None + + @property + def available(self): + return self.device_index is not None + + @property + def model_ready(self): + return _model_ready.is_set() and _model is not None + + def start(self): + if self.recording: + return False + if self.device_index is None: + self.error = "No audio input device found" + return False + self.frames = [] + self.error = None + try: + self._stream = self.pa.open( + format=FORMAT, channels=CHANNELS, rate=RATE, + input=True, input_device_index=self.device_index, + frames_per_buffer=CHUNK + ) + self.recording = True + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + return True + except Exception as e: + self.error = str(e) + return False + + def _loop(self): + max_frames = int(RATE / CHUNK * MAX_SECS) + count = 0 + import numpy as np + while self.recording and count < max_frames: + try: + data = self._stream.read(CHUNK, exception_on_overflow=False) + self.frames.append(data) + # Calculate level + a = np.frombuffer(data, dtype=np.int16) + self._last_level = np.sqrt(np.mean(a.astype(np.float32)**2)) if len(a) > 0 else 0 + count += 1 + except Exception: + break + self._last_level = 0 + + @property + def level(self): + """Current RMS level (0-32768 approx)""" + return self._last_level + + def stop(self): + self.recording = False + if self._thread: + self._thread.join(timeout=2) + if self._stream: + try: + self._stream.stop_stream() + self._stream.close() + except Exception: + pass + self._stream = None + return b''.join(self.frames) + + def close(self): + if self.recording: + self.stop() + self.pa.terminate() + + +def transcribe(audio_bytes): + """ + Transcribe raw PCM bytes (16kHz, mono, s16le) using local Vosk model. + Returns (text, error_string). + """ + if not audio_bytes: + return None, "No audio recorded" + + with _model_lock: + model = _model + + if model is None: + if not _model_ready.is_set(): + return None, "Model still loading, try again" + return None, f"Model not found — check {MODEL_PATH}" + + try: + from vosk import KaldiRecognizer + rec = KaldiRecognizer(model, RATE) + rec.SetWords(False) + + # Feed audio in chunks + chunk_size = CHUNK * 2 # bytes + for i in range(0, len(audio_bytes), chunk_size): + rec.AcceptWaveform(audio_bytes[i:i + chunk_size]) + + result = json.loads(rec.FinalResult()) + text = result.get("text", "").strip() + return (text or None), (None if text else "Nothing recognized") + except Exception as e: + return None, str(e) diff --git a/apps/tailcode/app.toml b/apps/tailcode/app.toml new file mode 100644 index 0000000..5bf9fd8 --- /dev/null +++ b/apps/tailcode/app.toml @@ -0,0 +1,10 @@ +[application] +name = "Tailcode" +version = "1.0.0" +descriptions = "Show Tailscale connection info and QR code for the KVM web UI." + +[author] +name = "cleavines01" + +[interaction] +requires_user_input = false diff --git a/apps/tailcode/main.py b/apps/tailcode/main.py new file mode 100644 index 0000000..f401989 --- /dev/null +++ b/apps/tailcode/main.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Tailcode - Tailscale QR Code App for NanoKVM Pro +Displays the Tailscale URL as a QR code and shows connection info. +Screen: 172x320 physical (portrait), 320x172 logical (landscape at 270deg rotation) +""" + +import subprocess +import time +import io +import qrcode +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from framebuffer import Framebuffer +from input import TouchScreen, GpioKeys + +SCREEN_W = 320 +SCREEN_H = 172 + +FONT_PATH = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' +FONT_BOLD = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' + +COLOR_BG = (10, 10, 30) +COLOR_WHITE = (255, 255, 255) +COLOR_CYAN = (0, 200, 255) +COLOR_YELLOW = (255, 220, 50) +COLOR_GRAY = (140, 140, 160) +COLOR_GREEN = (80, 220, 80) +COLOR_DIM = (70, 70, 90) + + +def get_tailscale_info(): + try: + ip = subprocess.check_output(['tailscale', 'ip', '--4'], + stderr=subprocess.DEVNULL).decode().strip() + except Exception: + ip = 'unavailable' + + try: + import json + raw = subprocess.check_output(['tailscale', 'status', '--json'], + stderr=subprocess.DEVNULL).decode() + data = json.loads(raw) + dns = data['Self']['DNSName'].rstrip('.') + except Exception: + dns = ip + + url = f'https://{dns}/kvm/' + return ip, dns, url + + +def make_qr_image(url: str, size: int) -> Image.Image: + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=4, + border=2, + ) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color='black', back_color='white').convert('RGB') + img = img.resize((size, size), Image.NEAREST) + return img + + +def render_screen(url: str, ip: str, dns: str) -> Image.Image: + canvas = Image.new('RGB', (SCREEN_W, SCREEN_H), COLOR_BG) + draw = ImageDraw.Draw(canvas) + + try: + font_small = ImageFont.truetype(FONT_PATH, 11) + font_medium = ImageFont.truetype(FONT_PATH, 13) + font_bold = ImageFont.truetype(FONT_BOLD, 14) + font_title = ImageFont.truetype(FONT_BOLD, 15) + except Exception: + font_small = font_medium = font_bold = font_title = ImageFont.load_default() + + # --- QR code (left side) --- + qr_size = 156 + qr_x = 4 + qr_y = (SCREEN_H - qr_size) // 2 + qr_img = make_qr_image(url, qr_size) + canvas.paste(qr_img, (qr_x, qr_y)) + + # Divider line + draw.line([(qr_x + qr_size + 4, 8), (qr_x + qr_size + 4, SCREEN_H - 8)], + fill=COLOR_GRAY, width=1) + + # --- Right side info --- + rx = qr_x + qr_size + 10 + ry = 10 + + # Title + draw.text((rx, ry), 'Tailcode', font=font_title, fill=COLOR_CYAN) + ry += 20 + + # Tailscale label + draw.text((rx, ry), 'TAILSCALE', font=font_small, fill=COLOR_GRAY) + ry += 14 + + # IP address + draw.text((rx, ry), ip, font=font_bold, fill=COLOR_GREEN) + ry += 20 + + # DNS name + draw.text((rx, ry), 'DNS:', font=font_small, fill=COLOR_GRAY) + ry += 13 + + parts = dns.split('.', 1) + draw.text((rx, ry), parts[0], font=font_medium, fill=COLOR_WHITE) + ry += 15 + if len(parts) > 1: + draw.text((rx, ry), '.' + parts[1], font=font_small, fill=COLOR_GRAY) + ry += 14 + + # URL hint + ry = SCREEN_H - 42 + draw.text((rx, ry), 'URL:', font=font_small, fill=COLOR_GRAY) + ry += 13 + draw.text((rx, ry), '/kvm/', font=font_medium, fill=COLOR_YELLOW) + ry += 16 + + # Press to exit + draw.text((rx, ry), 'Press to exit', font=font_small, fill=COLOR_DIM) + + # Scan hint at bottom of QR + hint = 'Scan to open' + bbox = draw.textbbox((0, 0), hint, font=font_small) + hw = bbox[2] - bbox[0] + draw.text((qr_x + (qr_size - hw) // 2, SCREEN_H - 14), + hint, font=font_small, fill=COLOR_GRAY) + + return canvas + + +def image_to_framebuffer(img: Image.Image, fb: Framebuffer): + phys_img = img.rotate(90, expand=True) # 172x320 physical (fixed: was -90, upside down) + phys_arr = np.array(phys_img) + r2 = (phys_arr[:, :, 0] >> 3).astype(np.uint16) + g2 = (phys_arr[:, :, 1] >> 2).astype(np.uint16) + b2 = (phys_arr[:, :, 2] >> 3).astype(np.uint16) + phys_rgb565 = (r2 << 11) | (g2 << 5) | b2 + + if fb.fbmem and fb.buffer: + phys_bytes = phys_rgb565.astype(' 60: + ip, dns, url = get_tailscale_info() + canvas = render_screen(url, ip, dns) + image_to_framebuffer(canvas, fb) + last_refresh = time.time() + + touch_event = touch.read_event(timeout=0.05) + if touch_event and touch_event[0] == 'touch_down': + print('Touch detected, exiting...') + break + + key_event = keys.read_event(timeout=0.05) + if key_event and key_event[0] in ('key_press', 'key_release'): + print('Key detected, exiting...') + break + + except KeyboardInterrupt: + print('Interrupted.') + finally: + fb.fill_screen((0, 0, 0)) + + +if __name__ == '__main__': + main() diff --git a/apps/tailscale/main.py b/apps/tailscale/main.py new file mode 100644 index 0000000..e2d3066 --- /dev/null +++ b/apps/tailscale/main.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Tailcode - Tailscale QR Code App for NanoKVM Pro +Displays the Tailscale URL as a QR code and shows connection info. +Screen: 172x320 physical (portrait), 320x172 logical (landscape at 270deg rotation) +""" + +import subprocess, time, io, qrcode, mmap +import numpy as np +from PIL import Image, ImageDraw, ImageFont +from input import TouchScreen, GpioKeys + +# ── Display ────────────────────────────────────────────────────────────────── +PW, PH = 172, 320 +LW, LH = 320, 172 +BG_COLOR = (10, 10, 30) + +class FB: + def __init__(self): + sz = PW * PH * 2 + self.fd = os.open('/dev/fb0', os.O_RDWR) + self.mm = mmap.mmap(self.fd, sz, mmap.MAP_SHARED, mmap.PROT_WRITE) + self.arr = np.frombuffer(self.mm, dtype=np.uint16).reshape(PH, PW) + def show(self, img: Image.Image): + p = img.rotate(90, expand=True) + a = np.array(p, dtype=np.uint16) + self.arr[:,:] = (a[:,:,0]>>3<<11)|(a[:,:,1]>>2<<5)|(a[:,:,2]>>3) + def close(self): + self.mm.close(); os.close(self.fd) + +FONT_PATH = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' +FONT_BOLD = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' + +COLOR_WHITE = (255, 255, 255) +COLOR_CYAN = (0, 200, 255) +COLOR_YELLOW = (255, 220, 50) +COLOR_GRAY = (140, 140, 160) +COLOR_GREEN = (80, 220, 80) +COLOR_DIM = (70, 70, 90) + +def get_tailscale_info(): + try: + ip = subprocess.check_output(['tailscale', 'ip', '--4'], stderr=subprocess.DEVNULL).decode().strip() + except Exception: ip = 'unavailable' + try: + import json + raw = subprocess.check_output(['tailscale', 'status', '--json'], stderr=subprocess.DEVNULL).decode() + data = json.loads(raw) + dns = data['Self']['DNSName'].rstrip('.') + except Exception: dns = ip + return ip, dns, f'https://{dns}/kvm/' + +def make_qr_image(url: str, size: int) -> Image.Image: + qr = qrcode.QRCode(box_size=4, border=2) + qr.add_data(url); qr.make(fit=True) + img = qr.make_image(fill_color='black', back_color='white').convert('RGB') + return img.resize((size, size), Image.NEAREST) + +def render_screen(url: str, ip: str, dns: str) -> Image.Image: + canvas = Image.new('RGB', (LW, LH), BG_COLOR) + draw = ImageDraw.Draw(canvas) + try: + f_small = ImageFont.truetype(FONT_PATH, 11) + f_medium = ImageFont.truetype(FONT_PATH, 13) + f_bold = ImageFont.truetype(FONT_BOLD, 14) + f_title = ImageFont.truetype(FONT_BOLD, 15) + except: f_small = f_medium = f_bold = f_title = ImageFont.load_default() + + qr_size = 156 + canvas.paste(make_qr_image(url, qr_size), (4, (LH - qr_size) // 2)) + draw.line([(qr_size + 8, 8), (qr_size + 8, LH - 8)], fill=COLOR_GRAY, width=1) + + rx, ry = qr_size + 14, 10 + draw.text((rx, ry), 'Tailcode', font=f_title, fill=COLOR_CYAN); ry += 20 + draw.text((rx, ry), 'TAILSCALE', font=f_small, fill=COLOR_GRAY); ry += 14 + draw.text((rx, ry), ip, font=f_bold, fill=COLOR_GREEN); ry += 20 + draw.text((rx, ry), 'DNS:', font=f_small, fill=COLOR_GRAY); ry += 13 + parts = dns.split('.', 1) + draw.text((rx, ry), parts[0], font=f_medium, fill=COLOR_WHITE); ry += 15 + if len(parts) > 1: draw.text((rx, ry), '.' + parts[1], font=f_small, fill=COLOR_GRAY); ry += 14 + ry = LH - 42 + draw.text((rx, ry), 'URL:', font=f_small, fill=COLOR_GRAY); ry += 13 + draw.text((rx, ry), '/kvm/', font=f_medium, fill=COLOR_YELLOW); ry += 16 + draw.text((rx, ry), 'Long-press to exit', font=f_small, fill=COLOR_DIM) + return canvas + +def main(): + import os + fb = FB() + ip, dns, url = get_tailscale_info() + canvas = render_screen(url, ip, dns) + fb.show(canvas) + + last_refresh = time.time() + running = True + try: + with TouchScreen() as touch, GpioKeys() as keys: + while running: + if time.time() - last_refresh > 60: + ip, dns, url = get_tailscale_info() + fb.show(render_screen(url, ip, dns)) + last_refresh = time.time() + kev = keys.read_event(timeout=0.05) + if kev and kev[0] == 'key_long_press': running = False + tev = touch.read_event(timeout=0) + if tev and tev[0] == 'touch_down': running = False + time.sleep(0.05) + finally: + fb.show(Image.new('RGB', (LW, LH), (0,0,0))) + fb.close() + +if __name__ == '__main__': + main()