From 994d5a51710a2c5bae504b49c88e6e683d434968 Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 15:08:59 -0600 Subject: [PATCH 01/16] Add stocks, tailcode, and picoclaw apps for NanoKVM Pro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new touchscreen apps for the NanoKVM Pro (172x320 display, rotary knob): - stocks: 5-symbol stock tracker with 5-day 1h candlestick charts. Touch to cycle symbols, rotary encoder + long-press to edit symbols. - tailcode: Tailscale connection info and QR code for the KVM web UI. Shows Tailscale URL, IP, and scannable QR code at a glance. - picoclaw: PicoClaw AI Agent UI — 7-page app with voice commands (OpenAI Whisper), agent chat, skills management, auth, and setup. Requires picoclaw v0.2.0+ installed on the device. All apps use the shared framebuffer.py / input.py pattern. Authored-by: Sideflare (cleavines01) --- apps/picoclaw/app.toml | 10 + apps/picoclaw/main.py | 627 +++++++++++++++++++++++++++++++ apps/picoclaw/picoclaw_cli.py | 102 +++++ apps/picoclaw/voice.py | 120 ++++++ apps/stocks/app.toml | 10 + apps/stocks/main.py | 687 ++++++++++++++++++++++++++++++++++ apps/tailcode/app.toml | 10 + apps/tailcode/main.py | 194 ++++++++++ 8 files changed, 1760 insertions(+) create mode 100644 apps/picoclaw/app.toml create mode 100644 apps/picoclaw/main.py create mode 100644 apps/picoclaw/picoclaw_cli.py create mode 100644 apps/picoclaw/voice.py create mode 100644 apps/stocks/app.toml create mode 100644 apps/stocks/main.py create mode 100644 apps/tailcode/app.toml create mode 100644 apps/tailcode/main.py 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..de99aa9 --- /dev/null +++ b/apps/picoclaw/main.py @@ -0,0 +1,627 @@ +#!/usr/bin/env python3 +""" +PicoClaw NanoKVM Screen App v1.0.0 +──────────────────────────────────── +7 pages navigated with the rotary knob and touchscreen: + Home · Agent · Voice · Skills · Auth · Setup · Status + +Controls: + Knob rotate → scroll / change selection + Knob press → select / confirm + Knob 2s hold → back to Home (or exit from Home) + Touch → tap buttons directly + +Voice commands use OpenAI Whisper API — set your key in Setup. +Agent sends messages to picoclaw agent -m via the configured profile. +""" + +import os, sys, time, json, threading, mmap, textwrap +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +import picoclaw_cli as pc +import voice as vc +from input import TouchScreen, GpioKeys, RotaryEncoder + +# ── Display ────────────────────────────────────────────────────────────────── +PW, PH = 172, 320 # physical (portrait) +LW, LH = 320, 172 # logical canvas (landscape) + +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) + +_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) + + +class FB: + """Fast framebuffer via numpy mmap.""" + 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): + # Logical 320×172 → rotate 90° CCW → Physical 172×320 + 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 drawbtn(d, x0, y0, x1, y1, label, font=FM, color=TEXT, bg=BTN, sel=False): + rrect(d, x0, y0, x1, y1, fill=BTNH if sel else bg, outline=ACCENT if sel else None) + centered(d, label, (x0+x1)//2, (y0+y1)//2, font, color) + + +# ── Pages ────────────────────────────────────────────────────────────────── +PG_HOME, PG_AGENT, PG_VOICE, PG_SKILLS, PG_AUTH, PG_SETUP, PG_STATUS = range(7) +HOME_TARGETS = [PG_AGENT, PG_VOICE, PG_SKILLS, PG_AUTH, PG_SETUP, PG_STATUS] +HOME_LABELS = ["Agent", "Voice", "Skills", "Auth", "Setup", "Status"] +HOME_ICONS = [">_ ", "(O)", "## ", "[K]", "{} ", " i "] + +# ── Config ──────────────────────────────────────────────────────────────────── +CFG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") +CFG_DEF = {"whisper_key": "", "whisper_url": "", "profile": "claude"} + +def load_cfg(): + try: + with open(CFG_PATH) as f: c = json.load(f) + for k, v in CFG_DEF.items(): c.setdefault(k, v) + return c + except: return dict(CFG_DEF) + +def save_cfg(c): + try: + with open(CFG_PATH, 'w') as f: json.dump(c, f, indent=2) + except Exception as e: print(f"cfg save: {e}") + + +# ── App ─────────────────────────────────────────────────────────────────────── +class App: + def __init__(self): + self.fb = FB() + self.rec = vc.Recorder() + self.cfg = load_cfg() + self._cache_lock = threading.Lock() + self._cache = {} + self._dirty = True + + # ── page state + self.page = PG_HOME + self.home_sel = 0 + + self.agent_hist = [] # [(role, text)] + self.agent_scroll = 0 + self.agent_busy = False + self.agent_msg = "" + + self.voice_state = "idle" # idle|recording|processing|done|error + self.voice_text = "" # transcript + self.voice_err = "" + + self.skills = [] + self.skills_sel = 0 + self.skills_scroll = 0 + + self.auth_sel = 0 # 0=Login 1=Logout 2=Status + self.auth_result = "" + self.auth_busy = False + + self.setup_sel = 0 + self.setup_profiles = pc.list_profiles() + self.setup_result = "" + + # ── initial background loads + self._bg('version', pc.get_version) + self._bg('auth', pc.auth_status) + + # ── Background task helper ──────────────────────────────────────────────── + def _bg(self, key, fn, *args, done=None): + 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 + if done: done() + threading.Thread(target=_run, daemon=True).start() + + def _get(self, key): + with self._cache_lock: return self._cache.get(key) + + # ── Navigation ──────────────────────────────────────────────────────────── + def goto(self, page): + self.page = page + self._dirty = True + if page == PG_SKILLS: self._bg('skills', pc.list_skills) + elif page == PG_STATUS: self._bg('status', pc.get_status) + elif page == PG_AUTH: self._bg('auth', pc.auth_status) + + # ── Input handlers ──────────────────────────────────────────────────────── + def on_rotate(self, delta): + p = self.page + if p == PG_HOME: self.home_sel = (self.home_sel + delta) % 6 + elif p == PG_AGENT: self.agent_scroll = max(0, self.agent_scroll + delta) + elif p == PG_SKILLS: self.skills_sel = max(0, min(len(self.skills)-1, self.skills_sel + delta)) + elif p == PG_AUTH: self.auth_sel = (self.auth_sel + delta) % 3 + elif p == PG_SETUP: self.setup_sel = (self.setup_sel + delta) % 5 + self._dirty = True + + def on_press(self): + p = self.page + if p == PG_HOME: self.goto(HOME_TARGETS[self.home_sel]) + elif p == PG_VOICE: self._toggle_voice() + elif p == PG_AGENT: + if self.voice_text and not self.agent_busy: + self._agent_send(self.voice_text) + self.voice_text = "" + elif p == PG_AUTH: self._auth_action(self.auth_sel) + elif p == PG_SETUP: self._setup_action(self.setup_sel) + elif p == PG_STATUS: self._bg('status', pc.get_status) + self._dirty = True + + def on_long_press(self): + if self.page != PG_HOME: + self.goto(PG_HOME) + else: + self.running = False + self._dirty = True + + def on_touch(self, raw_x, raw_y): + x, y = TouchScreen.map_coords_270(raw_x, raw_y) + # Top-left tap = back + if x < 44 and y < 30 and self.page != PG_HOME: + self.goto(PG_HOME); return + p = self.page + if p == PG_HOME: self._home_touch(x, y) + elif p == PG_AGENT: self._agent_touch(x, y) + elif p == PG_VOICE: self._voice_touch(x, y) + elif p == PG_AUTH: self._auth_touch(x, y) + elif p == PG_SETUP: self._setup_touch(x, y) + elif p == PG_STATUS and x > 255 and y < 30: self._bg('status', pc.get_status) + self._dirty = True + + def _home_touch(self, x, y): + if y < 22: return + col = min(2, x // 107) + row = min(1, (y - 22) // 65) + idx = row * 3 + col + if idx < len(HOME_TARGETS): + self.goto(HOME_TARGETS[idx]) + + def _agent_touch(self, x, y): + # MIC button bottom-right + if x > 268 and y > 144: + self._toggle_voice() + # Send pending transcript + elif self.voice_text and 8 < x < 260 and 158 < y < LH: + if not self.agent_busy: + self._agent_send(self.voice_text) + self.voice_text = "" + + def _voice_touch(self, x, y): + # Big mic circle ~(160,88) r=36 + if 118 < x < 202 and 48 < y < 128: + self._toggle_voice() + # "Send to Agent" button + elif self.voice_state == 'done' and self.voice_text and x > 195 and y > 149: + self.goto(PG_AGENT) + if self.voice_text and not self.agent_busy: + self._agent_send(self.voice_text) + self.voice_text = "" + + def _auth_touch(self, x, y): + if 74 < y < 112: + bw = (LW - 24) // 3 + col = min(2, (x - 8) // (bw + 4)) + self._auth_action(col) + + def _setup_touch(self, x, y): + IH, Y0 = 22, 26 + for i in range(5): + iy = Y0 + i * IH + if iy < y < iy + IH: + self.setup_sel = i + self._setup_action(i) + break + + # ── Voice ───────────────────────────────────────────────────────────────── + def _toggle_voice(self): + if self.voice_state in ('idle', 'done', 'error'): + ok = self.rec.start() + if ok: + self.voice_state = 'recording' + self.voice_text = "" + # auto-stop + def _auto(): + time.sleep(vc.MAX_SECS) + if self.voice_state == 'recording': + self._stop_recording() + threading.Thread(target=_auto, daemon=True).start() + else: + self.voice_state = 'error' + self.voice_err = self.rec.error or "Failed to open mic" + elif self.voice_state == 'recording': + self._stop_recording() + self._dirty = True + + def _stop_recording(self): + self.voice_state = 'processing' + self._dirty = True + wav = self.rec.stop() + + def _transcribe(): + key = self.cfg.get('whisper_key') + url = self.cfg.get('whisper_url') or None + text, err = vc.transcribe(wav, key, url) + if err: + self.voice_state = 'error' + self.voice_err = err + self.voice_text = "" + else: + self.voice_state = 'done' + self.voice_text = text or "" + # Auto-send if already on Agent page + if self.page == PG_AGENT and self.voice_text and not self.agent_busy: + self._agent_send(self.voice_text) + self.voice_text = "" + self._dirty = True + + threading.Thread(target=_transcribe, daemon=True).start() + + # ── Agent ───────────────────────────────────────────────────────────────── + def _agent_send(self, message): + if self.agent_busy: return + self.agent_busy = True + self.agent_msg = "Thinking..." + self.agent_hist.append(('you', message)) + self._dirty = True + + model = pc.get_profile_model(self.cfg.get('profile', 'claude')) + + def _run(): + reply, ok = pc.agent_message(message, model=model or None) + self.agent_hist.append(('ai', reply[:400])) + self.agent_scroll = max(0, len(self.agent_hist) - 5) + self.agent_busy = False + self.agent_msg = "" + self.voice_state = 'idle' + self._dirty = True + + threading.Thread(target=_run, daemon=True).start() + + # ── Auth ────────────────────────────────────────────────────────────────── + def _auth_action(self, sel): + if self.auth_busy: return + self.auth_busy = True + self.auth_result = "Working..." + self._dirty = True + + def _run(): + if sel == 0: ok, msg = pc.auth_login() + elif sel == 1: ok, msg = pc.auth_logout() + else: msg = pc.auth_status() + self.auth_result = (msg or "Done")[:80] + self.auth_busy = False + self._bg('auth', pc.auth_status) + self._dirty = True + + threading.Thread(target=_run, daemon=True).start() + + # ── Setup ───────────────────────────────────────────────────────────────── + def _setup_action(self, sel): + items = ['Whisper Key', 'Profile', 'Onboard', 'Gateway', 'Back'] + item = items[sel] + if item == 'Back': + self.goto(PG_HOME) + elif item == 'Profile': + profs = self.setup_profiles or ['claude', 'gemini'] + cur = self.cfg.get('profile', 'claude') + try: nxt = profs[(profs.index(cur) + 1) % len(profs)] + except: nxt = profs[0] + self.cfg['profile'] = nxt + save_cfg(self.cfg) + self.setup_result = f"Profile: {nxt}" + elif item == 'Whisper Key': + self.setup_result = "SSH in: edit config.json" + elif item == 'Onboard': + self.setup_result = "Running onboard..." + def _run(): + ok, msg = pc.run_onboard() + self.setup_result = (msg or "Done")[:60] + self._dirty = True + threading.Thread(target=_run, daemon=True).start() + elif item == 'Gateway': + ok, msg = pc.start_gateway() + self.setup_result = msg[:60] + self._dirty = True + + # ── Auth helper ─────────────────────────────────────────────────────────── + def _auth_ok(self): + r = self._get('auth') + if r and r[0] == 'ok': + t = r[1].lower() + return any(w in t for w in ('logged in', 'authenticated', 'active', 'ok')) + return False + + # ── Drawing ─────────────────────────────────────────────────────────────── + 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_VOICE: self._draw_voice(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): + rv = self._get('version') + ver = rv[1] if rv and rv[0] == 'ok' else '...' + aok = self._auth_ok() + topbar(d, f"PicoClaw v{ver}", dot=OK if aok else ERR) + + # 3×2 button grid — y=22..154 + bw, bh = LW // 3, (LH - 36) // 2 + for i, (lbl, ico) in enumerate(zip(HOME_LABELS, HOME_ICONS)): + col, row = i % 3, i // 2 + x0 = col * bw + 3; y0 = 22 + row * bh + 3 + x1 = x0 + bw - 6; y1 = y0 + bh - 6 + 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 + 5, y0 + 4), ico, font=FN, fill=DIM) + centered(d, lbl, (x0+x1)//2, (y0+y1)//2 + 5, FM, TEXT if sel else DIM) + + # Status strip + d.rectangle([0, LH-16, LW, LH], fill=PANEL) + prof = self.cfg.get('profile', 'claude') + d.text((8, LH-13), f"Profile: {prof} | Long-press: exit", font=FN, fill=DIM) + + def _draw_agent(self, d): + topbar(d, "< Agent", dot=OK if self._auth_ok() else WARN) + # Conversation area y=22..143 + y, lh = 24, 13 + hist = self.agent_hist + start = max(0, len(hist) - 6 - self.agent_scroll) + shown = hist[start:start + 6] + if not shown: + d.text((10, 40), "Speak into the mic or go to Voice page", font=FS, fill=DIM) + d.text((10, 56), "Knob press sends pending transcript", font=FN, fill=DIM) + else: + for role, text in shown: + color = ACCENT if role == 'you' else OK + prefix = "You: " if role == 'you' else " AI: " + for line in wordwrap(prefix + text, LW - 18, d, FN)[:3]: + if y + lh > 143: break + d.text((8, y), line, font=FN, fill=color) + y += lh + if self.agent_busy: + d.text((8, 131), self.agent_msg, font=FN, fill=WARN) + + # Bottom bar y=143..172 + d.rectangle([0, 143, LW, LH], fill=PANEL) + d.line([0, 143, LW, 143], fill=DIM, width=1) + if self.voice_state == 'recording': + d.ellipse([8, 149, 18, 159], fill=REC) + d.text((22, 148), "Recording... press knob to stop", font=FS, fill=WARN) + elif self.voice_state == 'processing': + d.text((8, 148), "Transcribing...", font=FS, fill=WARN) + elif self.voice_text: + d.text((8, 147), f"> {self.voice_text[:34]}", font=FS, fill=TEXT) + d.text((8, 160), "Press knob to send to agent", font=FN, fill=ACCENT) + else: + d.text((8, 153), "Tap [MIC] or knob to record voice", font=FN, fill=DIM) + mic_col = REC if self.voice_state == 'recording' else BTNH + rrect(d, LW-48, 145, LW-4, LH-4, fill=mic_col) + centered(d, "MIC", LW-26, 157, FM, TEXT) + + def _draw_voice(self, d): + topbar(d, "< Voice", dot=DIM) + SC = { + 'idle': (DIM, "Press knob or tap to record"), + 'recording': (REC, "Recording... press to stop"), + 'processing': (WARN, "Transcribing audio..."), + 'done': (OK, "Done!"), + 'error': (ERR, "Error"), + } + col, label = SC.get(self.voice_state, (DIM, "")) + # Big mic circle + cx, cy, r = LW // 2, 85, 36 + d.ellipse([cx-r-4, cy-r-4, cx+r+4, cy+r+4], fill=PANEL, outline=col, width=2) + d.ellipse([cx-r, cy-r, cx+r, cy+r], fill=col if self.voice_state == 'recording' else BTN) + centered(d, "MIC", cx, cy, FL, TEXT if self.voice_state == 'recording' else col) + centered(d, label, LW//2, 127, FS, col) + if self.voice_state == 'recording': + centered(d, f"(max {vc.MAX_SECS}s)", LW//2, 139, FN, DIM) + + d.line([10, 146, LW-10, 146], fill=DIM, width=1) + + if self.voice_state == 'error': + for i, l in enumerate(wordwrap(self.voice_err, LW-16, d, FN)[:2]): + d.text((8, 150 + i*12), l, font=FN, fill=ERR) + elif self.voice_text: + for i, l in enumerate(wordwrap(self.voice_text, LW-16, d, FS)[:2]): + d.text((8, 150 + i*13), l, font=FS, fill=TEXT) + if self.voice_state == 'done': + rrect(d, LW-122, LH-22, LW-4, LH-4, fill=BTNH) + centered(d, "> Send to Agent", LW-63, LH-12, FN, TEXT) + else: + d.text((8, 151), "Transcript will appear here", font=FN, fill=DIM) + + def _draw_skills(self, d): + r = self._get('skills') + if r and r[0] == 'ok': + self.skills = r[1] if isinstance(r[1], list) else [] + cnt = len(self.skills) + topbar(d, f"< Skills ({cnt})", dot=OK if cnt else DIM) + if not r: + centered(d, "Loading...", LW//2, LH//2, FM, WARN); return + if not self.skills: + centered(d, "No skills installed", LW//2, 70, FM, DIM) + d.text((10, 88), "picoclaw skills install ", font=FS, fill=DIM); return + IH, Y0 = 22, 26 + for i, sk in enumerate(self.skills[self.skills_scroll:self.skills_scroll + 6]): + idx = i + self.skills_scroll + y = Y0 + i * IH + sel = (idx == self.skills_sel) + if sel: d.rectangle([4, y, LW-4, y+IH-2], fill=SEL) + d.text((8 if not sel else 18, y+4), (">" if sel else " ") + " " + sk[:40], font=FS, fill=TEXT if sel else DIM) + d.rectangle([0, LH-16, LW, LH], fill=PANEL) + d.text((8, LH-13), "Scroll: knob Long-press: back", font=FN, fill=DIM) + + def _draw_auth(self, d): + aok = self._auth_ok() + topbar(d, "< Auth", dot=OK if aok else ERR) + r = self._get('auth') + status_line = (r[1].split('\n')[0][:42] if r and r[0] == 'ok' else "Checking...") + rrect(d, 6, 25, LW-6, 70, fill=PANEL, r=4) + d.ellipse([12, 36, 22, 46], fill=OK if aok else ERR) + d.text((28, 35), status_line, font=FS, fill=OK if aok else DIM) + if self.auth_result: + for i, l in enumerate(wordwrap(self.auth_result, LW-20, d, FN)[:2]): + d.text((12, 52 + i*11), l, font=FN, fill=DIM) + LBLS = ["Login", "Logout", "Status"] + bw = (LW - 24) // 3 + for i, lbl in enumerate(LBLS): + x0 = 8 + i*(bw+4); x1 = x0 + bw + drawbtn(d, x0, 74, x1, 112, lbl, FM, sel=(i == self.auth_sel)) + if self.auth_busy: + centered(d, "Working...", LW//2, 125, FS, WARN) + d.rectangle([0, LH-16, LW, LH], fill=PANEL) + d.text((8, LH-13), "Knob: select Press: confirm Long: back", font=FN, fill=DIM) + + def _draw_setup(self, d): + topbar(d, "< Setup", dot=DIM) + items = ['Whisper Key', 'Profile', 'Onboard', 'Gateway', 'Back'] + vals = [ + "set" if self.cfg.get('whisper_key') else "not set", + self.cfg.get('profile', 'claude'), + "run", "start", "", + ] + IH, Y0 = 22, 26 + for i, (item, val) in enumerate(zip(items, vals)): + y = Y0 + i * IH + sel = (i == self.setup_sel) + if sel: d.rectangle([4, y, LW-4, y+IH-2], fill=SEL) + d.text((20, y+4), (">" if sel else " ") + " " + item, font=FS, fill=TEXT if sel else DIM) + if val: + vx = LW - 8 - len(val)*6 + d.text((max(vx, 145), y+4), val, font=FN, fill=ACCENT if sel else DIM) + if self.setup_result: + d.rectangle([0, LH-20, LW, LH], fill=PANEL) + d.text((8, LH-17), self.setup_result[:52], font=FN, fill=OK) + else: + d.rectangle([0, LH-16, LW, LH], fill=PANEL) + d.text((8, LH-13), "Knob: scroll Press: select Long: back", font=FN, fill=DIM) + + def _draw_status(self, d): + topbar(d, "< Status", dot=DIM) + rrect(d, LW-64, 3, LW-4, 19, fill=BTN, r=3) + centered(d, "Refresh", LW-34, 11, FN, TEXT) + r = self._get('status') + if not r: + centered(d, "Loading...", LW//2, LH//2, FM, WARN); return + lines = r[1].split('\n') if r[0] == 'ok' else [f"Error: {r[1]}"] + y = 26 + for line in lines[:9]: + if y > LH - 8: break + d.text((8, y), line[:44], font=FN, fill=TEXT) + y += 15 + + # ── Main loop ───────────────────────────────────────────────────────────── + def run(self): + self.running = True + print("PicoClaw app starting...") + with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as rotary: + while self.running: + # Rotary + rot = rotary.read_event(timeout=0) + if rot is not None: + self.on_rotate(rot) + + # Keys (knob button) + kev = keys.read_event(timeout=0) + if kev: + if kev[0] == 'key_long_press': + self.on_long_press() + elif kev[0] == 'key_release' and kev[1] == 'ENTER' and not kev[4]: + self.on_press() + + # Touch + tev = touch.read_event(timeout=0) + if tev and tev[0] == 'touch_down': + self.on_touch(tev[1], tev[2]) + + if self._dirty: + self.draw() + + time.sleep(0.03) # ~33 fps + + def cleanup(self): + if self.rec: self.rec.close() + if self.fb: self.fb.close() + + +if __name__ == '__main__': + app = App() + try: + app.run() + except KeyboardInterrupt: + pass + finally: + app.cleanup() + print("PicoClaw app stopped") 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..9da70b5 --- /dev/null +++ b/apps/picoclaw/voice.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Audio recording and OpenAI Whisper transcription.""" +import pyaudio +import wave +import io +import threading +import time + +RATE = 16000 +CHANNELS = 1 +FORMAT = pyaudio.paInt16 +CHUNK = 1024 +MAX_SECS = 10 + + +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() + + def _find_input(self): + for i in range(self.pa.get_device_count()): + info = self.pa.get_device_info_by_index(i) + if info['maxInputChannels'] > 0: + return i + return None + + @property + def available(self): + return self.device_index 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 + while self.recording and count < max_frames: + try: + data = self._stream.read(CHUNK, exception_on_overflow=False) + self.frames.append(data) + count += 1 + except Exception: + break + + 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 self._to_wav() + + def _to_wav(self): + if not self.frames: + return None + buf = io.BytesIO() + with wave.open(buf, 'wb') as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(self.pa.get_sample_size(FORMAT)) + wf.setframerate(RATE) + wf.writeframes(b''.join(self.frames)) + buf.seek(0) + return buf + + def close(self): + if self.recording: + self.stop() + self.pa.terminate() + + +def transcribe(wav_buf, api_key, base_url=None): + """Transcribe audio using OpenAI Whisper API.""" + if not wav_buf: + return None, "No audio recorded" + if not api_key: + return None, "Whisper API key not set — configure in Setup" + try: + import openai + kwargs = {"api_key": api_key} + if base_url: + kwargs["base_url"] = base_url + client = openai.OpenAI(**kwargs) + wav_buf.seek(0) + result = client.audio.transcriptions.create( + model="whisper-1", + file=("audio.wav", wav_buf, "audio/wav"), + ) + return result.text, None + except Exception as e: + return None, str(e) diff --git a/apps/stocks/app.toml b/apps/stocks/app.toml new file mode 100644 index 0000000..7674a11 --- /dev/null +++ b/apps/stocks/app.toml @@ -0,0 +1,10 @@ +[application] +name = "stocks" +version = "1.0.0" +descriptions = "5-symbol stock tracker with 5-day 1h candlestick charts. Touch to cycle symbols, long-press key to edit symbols via wheel." + +[author] +name = "cleavines01" + +[interaction] +requires_user_input = true diff --git a/apps/stocks/main.py b/apps/stocks/main.py new file mode 100644 index 0000000..1b93def --- /dev/null +++ b/apps/stocks/main.py @@ -0,0 +1,687 @@ +#!/usr/bin/env python3 +""" +Stock Tracker App for NanoKVM Pro +- Rotary encoder (event0, EV_REL): one detent = one letter step (debounced) +- Push button ENTER (event1): short = next char, long = save & exit editor / exit app +- Editor touch: + * Tap symbol slot -> select that slot for editing + * Tap char in slot -> select that specific char position + * Tap/drag alphabet -> select letter directly + * Tap SAVE button -> save & return to chart +""" + +import time +import json +import os +import math +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from framebuffer import Framebuffer +from input import TouchScreen, GpioKeys, RotaryEncoder + +CONFIG_FILE = '/etc/kvm/stocks_config.json' +DEFAULT_SYMBOLS = ['AAPL', 'MSFT', 'NVDA', 'TSLA', 'SPY'] + +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' +FONT_MONO = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf' + +COLOR_BG = (8, 12, 24) +COLOR_GRID = (30, 40, 60) +COLOR_WHITE = (230, 230, 230) +COLOR_GRAY = (120, 120, 140) +COLOR_CYAN = (0, 200, 220) +COLOR_GREEN = (60, 200, 80) +COLOR_RED = (220, 60, 60) +COLOR_YELLOW = (240, 210, 50) +COLOR_SEL = (50, 80, 160) +COLOR_ORANGE = (255, 140, 20) +COLOR_FOCUS = (60, 120, 240) +COLOR_FOCUS_BG = (18, 35, 75) +COLOR_DIM = (50, 55, 70) +COLOR_SAVE = (30, 130, 60) +COLOR_SAVE_HI = (50, 200, 90) + +ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ.' + +TIMEFRAMES = [ + ('5d', '1h', '5d/1h'), + ('1d', '5m', '1d/5m'), + ('1mo', '1d', '1mo/1d'), + ('6mo', '1wk', '6mo/wk'), + ('1y', '1wk', '1yr/wk'), +] + +FOCUS_SYMBOL = 0 +FOCUS_CHART = 1 +FOCUS_GEAR = 2 + +HEADER_H = 30 + +ZONE_SYM_X1, ZONE_SYM_X2 = 0, 90 +ZONE_CHT_X1, ZONE_CHT_X2 = 91, 284 +ZONE_GEAR_X1, ZONE_GEAR_X2 = 285, 319 + +# Editor layout constants +ED_HEADER_H = 20 # title bar height +ED_SLOT_Y1 = 20 # symbol slots top +ED_SLOT_Y2 = 62 # symbol slots bottom +ED_SLOT_H = ED_SLOT_Y2 - ED_SLOT_Y1 +ED_ALPHA_Y1 = 66 # alphabet strip top +ED_ALPHA_Y2 = 126 # alphabet strip bottom +ED_ALPHA_H = ED_ALPHA_Y2 - ED_ALPHA_Y1 +# Bottom button bar (y=148..172, full width split in half) +ED_BTN_Y1 = 148 +ED_BTN_Y2 = 172 +ED_CANCEL_X2 = 159 # CANCEL: x=0..159 +ED_SAVE_X1 = 160 # SAVE: x=160..319 + +SLOT_W = 56 +SLOT_GAP = 2 +SLOTS_TOTAL = 5 * SLOT_W + 4 * SLOT_GAP # 292 +SLOT_START_X = (SCREEN_W - SLOTS_TOTAL) // 2 # 14 + +ENCODER_DEBOUNCE = 0.20 # seconds between encoder steps (200ms handles mechanical bounce) + +# Auto-refresh intervals: (seconds, display label). 0 = off. +REFRESH_OPTIONS = [ + (0, 'Off'), + (60, '1 min'), + (300, '5 min'), + (900, '15 min'), + (1800, '30 min'), + (3600, '1 hr'), +] + + +def load_config(): + try: + with open(CONFIG_FILE) as f: + d = json.load(f) + syms = d.get('symbols', DEFAULT_SYMBOLS)[:5] + while len(syms) < 5: + syms.append('SPY') + return syms + except Exception: + return list(DEFAULT_SYMBOLS) + + +def save_config(symbols): + try: + os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True) + with open(CONFIG_FILE, 'w') as f: + json.dump({'symbols': symbols}, f) + except Exception: + pass + + +def fetch_candles(symbol, period='5d', interval='1h'): + try: + import yfinance as yf + df = yf.download(symbol, period=period, interval=interval, + progress=False, auto_adjust=True) + if hasattr(df.columns, 'levels'): + df.columns = df.columns.get_level_values(0) + if df is None or len(df) < 2: + return None + result = [] + for _, row in df.iterrows(): + try: + result.append((float(row['Open']), float(row['High']), + float(row['Low']), float(row['Close']))) + except Exception: + pass + return result if result else None + except Exception as e: + print(f'fetch error: {e}') + return None + + +def draw_candles(draw, candles, ax, ay, aw, ah): + if not candles: + draw.text((ax + aw // 2 - 20, ay + ah // 2), 'NO DATA', fill=COLOR_GRAY) + return + highs = [c[1] for c in candles] + lows = [c[2] for c in candles] + pmax = max(highs) * 1.001 + pmin = min(lows) * 0.999 + prng = pmax - pmin or 1 + + def py(p): + return int(ay + ah - (p - pmin) / prng * ah) + + n = len(candles) + cw = max(2, aw // n - 1) + sp = aw / n + + for i in range(1, 4): + gy = ay + int(ah * i / 4) + draw.line([(ax, gy), (ax + aw, gy)], fill=COLOR_GRID, width=1) + + for i, (o, h, l, c) in enumerate(candles): + cx = int(ax + i * sp + sp / 2) + col = COLOR_GREEN if c >= o else COLOR_RED + draw.line([(cx, py(h)), (cx, py(l))], fill=col, width=1) + bt = py(max(o, c)) + bb = max(py(min(o, c)), bt + 1) + draw.rectangle([cx - cw // 2, bt, cx + cw // 2, bb], fill=col) + + +def draw_gear(draw, cx, cy, r=10, color=COLOR_GRAY): + draw.ellipse((cx - r + 3, cy - r + 3, cx + r - 3, cy + r - 3), + outline=color, width=2) + for i in range(8): + a = (i / 8) * 2 * math.pi + x1 = cx + math.cos(a) * (r - 3) + y1 = cy + math.sin(a) * (r - 3) + x2 = cx + math.cos(a) * r + y2 = cy + math.sin(a) * r + draw.line((x1, y1, x2, y2), fill=color, width=3) + + +def render_chart(symbol, candles, sym_idx, total, focus, tf_label): + img = Image.new('RGB', (SCREEN_W, SCREEN_H), COLOR_BG) + draw = ImageDraw.Draw(img) + try: + fsm = ImageFont.truetype(FONT_PATH, 10) + fmd = ImageFont.truetype(FONT_PATH, 12) + fbg = ImageFont.truetype(FONT_BOLD, 17) + except Exception: + fsm = fmd = fbg = ImageFont.load_default() + + draw.rectangle([0, 0, SCREEN_W - 1, HEADER_H], fill=(12, 18, 40)) + + zones = [(ZONE_SYM_X1, ZONE_SYM_X2), + (ZONE_CHT_X1, ZONE_CHT_X2), + (ZONE_GEAR_X1, ZONE_GEAR_X2)] + zx1, zx2 = zones[focus] + draw.rectangle([zx1, 0, zx2, HEADER_H], fill=COLOR_FOCUS_BG) + draw.rectangle([zx1, HEADER_H - 2, zx2, HEADER_H], fill=COLOR_FOCUS) + + sym_col = COLOR_CYAN if focus == FOCUS_SYMBOL else COLOR_WHITE + draw.text((4, 7), symbol, font=fbg, fill=sym_col) + for i in range(total): + col = COLOR_CYAN if i == sym_idx else (45, 45, 70) + draw.ellipse([4 + i * 8, 2, 9 + i * 8, 7], fill=col) + + cht_col = COLOR_CYAN if focus == FOCUS_CHART else COLOR_GRAY + if candles: + lc = candles[-1][3] + fo = candles[0][0] + chg = lc - fo + pct = chg / fo * 100 if fo else 0 + pcol = COLOR_GREEN if chg >= 0 else COLOR_RED + sign = '+' if chg >= 0 else '' + draw.text((ZONE_CHT_X1 + 4, 3), f'${lc:.2f}', font=fmd, fill=COLOR_WHITE) + draw.text((ZONE_CHT_X1 + 4, 17), f'{sign}{pct:.2f}%', font=fsm, fill=pcol) + draw.text((ZONE_CHT_X1 + 80, 10), tf_label, font=fsm, fill=cht_col) + else: + draw.text((ZONE_CHT_X1 + 4, 10), 'Loading...', font=fsm, fill=COLOR_GRAY) + draw.text((ZONE_CHT_X1 + 80, 10), tf_label, font=fsm, fill=cht_col) + + gear_col = COLOR_CYAN if focus == FOCUS_GEAR else COLOR_GRAY + gcx = ZONE_GEAR_X1 + (ZONE_GEAR_X2 - ZONE_GEAR_X1) // 2 + draw_gear(draw, gcx, HEADER_H // 2, r=10, color=gear_col) + + draw_candles(draw, candles, 2, HEADER_H + 2, SCREEN_W - 4, + SCREEN_H - HEADER_H - 14) + + ts = time.strftime('%H:%M') + draw.text((SCREEN_W - 35, SCREEN_H - 12), ts, font=fsm, fill=COLOR_GRAY) + draw.text((2, SCREEN_H - 12), 'Wheel:focus Press:action Long:exit', + font=fsm, fill=COLOR_DIM) + return img + + +def render_editor(syms, editing_sym, editing_char, char_idx): + img = Image.new('RGB', (SCREEN_W, SCREEN_H), COLOR_BG) + draw = ImageDraw.Draw(img) + try: + fsm = ImageFont.truetype(FONT_PATH, 10) + fmed = ImageFont.truetype(FONT_PATH, 12) + fbg = ImageFont.truetype(FONT_BOLD, 14) + fmn = ImageFont.truetype(FONT_MONO, 13) + fal = ImageFont.truetype(FONT_BOLD, 13) # alphabet normal + fal2 = ImageFont.truetype(FONT_BOLD, 18) # alphabet selected + except Exception: + fsm = fmed = fbg = fmn = fal = fal2 = ImageFont.load_default() + + # --- Header --- + draw.rectangle([0, 0, SCREEN_W, ED_HEADER_H], fill=(14, 22, 48)) + draw.text((4, 3), 'EDIT SYMBOLS', font=fbg, fill=COLOR_CYAN) + + # --- Symbol slots --- + char_w = 10 # pixels per char in slot + for i, sym in enumerate(syms): + sx = SLOT_START_X + i * (SLOT_W + SLOT_GAP) + sel = (i == editing_sym) + bg = (22, 38, 80) if sel else (16, 24, 48) + border = COLOR_CYAN if sel else (50, 60, 90) + draw.rectangle([sx, ED_SLOT_Y1, sx + SLOT_W - 1, ED_SLOT_Y2 - 1], + fill=bg, outline=border, width=1) + sd = sym.ljust(5)[:5] + for ci, ch in enumerate(sd): + cx2 = sx + 3 + ci * char_w + cy2 = ED_SLOT_Y1 + (ED_SLOT_H - 16) // 2 + if sel and ci == editing_char: + # Highlighted current char + draw.rectangle([cx2 - 1, cy2 - 1, cx2 + char_w - 1, cy2 + 15], + fill=COLOR_ORANGE) + draw.text((cx2, cy2), ch, font=fmn, fill=COLOR_BG) + else: + draw.text((cx2, cy2), ch, font=fmn, + fill=COLOR_WHITE if sel else (80, 90, 110)) + + # --- Divider --- + draw.line([(0, ED_ALPHA_Y1 - 2), (SCREEN_W, ED_ALPHA_Y1 - 2)], + fill=(30, 40, 70), width=1) + + # --- Alphabet strip --- + n_chars = len(ALPHABET) + cell_w = SCREEN_W / n_chars # ~11.85px each + + # Background + draw.rectangle([0, ED_ALPHA_Y1, SCREEN_W, ED_ALPHA_Y2], fill=(10, 16, 36)) + + for ai, ch in enumerate(ALPHABET): + cx2 = int(ai * cell_w + cell_w / 2) + sel = (ai == char_idx) + if sel: + # Highlight background for selected letter + bx1 = int(ai * cell_w) + bx2 = int((ai + 1) * cell_w) + draw.rectangle([bx1, ED_ALPHA_Y1, bx2, ED_ALPHA_Y2], + fill=(30, 60, 120)) + # Large selected letter centered vertically + bbox = draw.textbbox((0, 0), ch, font=fal2) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + ty = ED_ALPHA_Y1 + (ED_ALPHA_H - th) // 2 - bbox[1] + draw.text((cx2 - tw // 2, ty), ch, font=fal2, fill=COLOR_YELLOW) + else: + bbox = draw.textbbox((0, 0), ch, font=fal) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + ty = ED_ALPHA_Y1 + (ED_ALPHA_H - th) // 2 - bbox[1] + # Dim neighbours, brighter as distance shrinks + dist = min(abs(ai - char_idx), n_chars - abs(ai - char_idx)) + alpha = max(60, 180 - dist * 18) + col = (alpha, alpha, alpha) + draw.text((cx2 - tw // 2, ty), ch, font=fal, fill=col) + + # --- Instructions row --- + iy = ED_ALPHA_Y2 + 4 + draw.text((2, iy), 'Wheel / drag strip: change letter', font=fsm, fill=COLOR_GRAY) + draw.text((2, iy + 12), 'Tap slot or char to jump position', font=fsm, fill=COLOR_GRAY) + + # --- Bottom button bar --- + # CANCEL (left half) + draw.rectangle([0, ED_BTN_Y1, ED_CANCEL_X2, ED_BTN_Y2], + fill=(60, 20, 20), outline=(160, 60, 60), width=1) + bbox = draw.textbbox((0, 0), 'CANCEL', font=fmed) + tw = bbox[2] - bbox[0] + draw.text(((ED_CANCEL_X2 - tw) // 2, ED_BTN_Y1 + 5), 'CANCEL', font=fmed, fill=(220, 100, 100)) + + # SAVE (right half) + draw.rectangle([ED_SAVE_X1, ED_BTN_Y1, SCREEN_W - 1, ED_BTN_Y2], + fill=(20, 70, 30), outline=(60, 180, 80), width=1) + bbox = draw.textbbox((0, 0), 'SAVE', font=fmed) + tw = bbox[2] - bbox[0] + sx_mid = ED_SAVE_X1 + (SCREEN_W - ED_SAVE_X1) // 2 + draw.text((sx_mid - tw // 2, ED_BTN_Y1 + 5), 'SAVE', font=fmed, fill=(80, 220, 100)) + + return img + + +def render_gear_menu(selected, refresh_label): + img = Image.new('RGB', (SCREEN_W, SCREEN_H), COLOR_BG) + draw = ImageDraw.Draw(img) + try: + fsm = ImageFont.truetype(FONT_PATH, 10) + fmd = ImageFont.truetype(FONT_PATH, 14) + fbg = ImageFont.truetype(FONT_BOLD, 16) + except Exception: + fsm = fmd = fbg = ImageFont.load_default() + + draw.text((4, 4), 'SETTINGS', font=fbg, fill=COLOR_CYAN) + options = [ + 'Refresh Data Now', + f'Auto-Refresh: {refresh_label}', + 'Back', + ] + for i, label in enumerate(options): + y = 35 + i * 30 + if i == selected: + draw.rectangle([4, y - 2, SCREEN_W - 4, y + 24], + fill=COLOR_FOCUS_BG, outline=COLOR_FOCUS, width=1) + # Show arrow hint on the refresh row + suffix = ' < >' if i == 1 else '' + draw.text((12, y + 4), label + suffix, font=fmd, + fill=COLOR_CYAN if i == selected else COLOR_WHITE) + + draw.text((4, SCREEN_H - 24), 'Wheel: navigate Press: select/cycle', font=fsm, fill=COLOR_GRAY) + draw.text((4, SCREEN_H - 12), 'Long press: exit app', font=fsm, fill=COLOR_GRAY) + return img + + +def image_to_fb(img, fb): + phys = img.rotate(90, expand=True) + arr = np.array(phys, dtype=np.uint16) + rgb565 = (((arr[:,:,0] >> 3) << 11) | + ((arr[:,:,1] >> 2) << 5) | + (arr[:,:,2] >> 3)).astype('= 0 else 0 + + def do_save(): + self.symbols = [s.strip('.').strip() or 'SPY' for s in syms] + save_config(self.symbols) + self.cache.clear() + + def redraw(): + image_to_fb(render_editor(syms, editing_sym, editing_char, char_idx), self.fb) + + redraw() + + while True: + # --- Encoder: one step per detent, debounced --- + re = encoder.read_event(timeout=0) + if re: + _, delta = re + now = time.time() + if delta != 0 and (now - last_encoder_step) >= ENCODER_DEBOUNCE: + last_encoder_step = now + step = 1 if delta > 0 else -1 + set_char(char_idx + step) + redraw() + + # --- Touch --- + te = touch.read_event(timeout=0) + if te: + ev, raw_x, raw_y, _ = te + sx, sy = TouchScreen.map_coords_270(raw_x, raw_y) + + if ev == 'touch_down': + # Classify where the touch started + if ED_BTN_Y1 <= sy <= ED_BTN_Y2: + touch_ctx = 'btn' + elif ED_ALPHA_Y1 <= sy <= ED_ALPHA_Y2: + touch_ctx = 'alpha' + set_char(alpha_idx_from_x(sx)) + redraw() + else: + slot_i, char_i = slot_and_char_from_touch(sx, sy) + if slot_i is not None: + touch_ctx = 'slot' + move_to(slot_i, char_i) + redraw() + else: + touch_ctx = None + + elif ev == 'touch_move': + # Only drag-update letter if touch started on alphabet strip + if touch_ctx == 'alpha' and ED_ALPHA_Y1 <= sy <= ED_ALPHA_Y2: + new_idx = alpha_idx_from_x(sx) + if new_idx != char_idx: + set_char(new_idx) + redraw() + + elif ev == 'touch_up': + if touch_ctx == 'btn': + if sx <= ED_CANCEL_X2: # CANCEL + return # discard changes + else: # SAVE + do_save() + return + touch_ctx = None + + # --- Button --- + ke = keys.read_event(timeout=0.02) + if ke: + ev, kn, _, _, _ = ke + if ev == 'key_long_press': + do_save() + return + elif ev == 'key_press' and kn == 'ENTER': + editing_char = (editing_char + 1) % 5 + ci = ALPHABET.find(syms[editing_sym][editing_char]) + char_idx = ci if ci >= 0 else 0 + redraw() + + def run_gear_menu(self, keys, touch, encoder): + selected = 0 + opts_count = 3 + last_step = 0.0 + + def redraw(): + image_to_fb(render_gear_menu(selected, self.refresh_label()), self.fb) + + redraw() + while True: + te = touch.read_event(timeout=0) + if te and te[0] == 'touch_down': + return False + + re = encoder.read_event(timeout=0) + if re: + _, delta = re + now = time.time() + if delta != 0 and (now - last_step) >= ENCODER_DEBOUNCE: + last_step = now + step = 1 if delta > 0 else -1 + selected = (selected + step) % opts_count + redraw() + + ke = keys.read_event(timeout=0.02) + if ke: + ev, kn, _, _, _ = ke + if ev == 'key_long_press': + return True + elif ev == 'key_press' and kn == 'ENTER': + if selected == 0: # Refresh now + self.cache.clear() + return False + elif selected == 1: # Cycle refresh interval + self.refresh_idx = (self.refresh_idx + 1) % len(REFRESH_OPTIONS) + redraw() + elif selected == 2: # Back + return False + + def run(self): + print('Stock Tracker starting...') + self.show_chart() + last_auto = time.time() + last_enc_step = 0.0 + td_time = td_x = td_y = 0 + touching = False + + with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as encoder: + while True: + ri = self.refresh_interval() + if ri > 0 and time.time() - last_auto > ri: + self.cache.clear() + self.show_chart() + last_auto = time.time() + + # Rotary encoder -> cycle focus (one step per detent) + re = encoder.read_event(timeout=0) + if re: + _, delta = re + now = time.time() + if delta != 0 and (now - last_enc_step) >= ENCODER_DEBOUNCE: + last_enc_step = now + step = 1 if delta > 0 else -1 + self.focus = (self.focus + step) % 3 + self.show_chart() + + # Touch + te = touch.read_event(timeout=0) + if te: + ev, x, y, _ = te + sx, sy = TouchScreen.map_coords_270(x, y) + + if ev == 'touch_down': + td_time = time.time() + td_x, td_y = sx, sy + touching = True + + elif ev == 'touch_up' and touching: + touching = False + dur = time.time() - td_time + if dur > 1.5: + break + + if td_y <= HEADER_H: + if ZONE_SYM_X1 <= td_x <= ZONE_SYM_X2: + if self.focus == FOCUS_SYMBOL: + self.run_editor(keys, touch, encoder) + else: + self.focus = FOCUS_SYMBOL + self.show_chart() + elif ZONE_CHT_X1 <= td_x <= ZONE_CHT_X2: + if self.focus == FOCUS_CHART: + self.tf_idx = (self.tf_idx + 1) % len(TIMEFRAMES) + self.cache.clear() + else: + self.focus = FOCUS_CHART + self.show_chart() + elif ZONE_GEAR_X1 <= td_x <= ZONE_GEAR_X2: + if self.focus == FOCUS_GEAR: + if self.run_gear_menu(keys, touch, encoder): + break + else: + self.focus = FOCUS_GEAR + self.show_chart() + else: + self.current = (self.current + 1) % len(self.symbols) + self.show_chart() + + # Button + ke = keys.read_event(timeout=0.02) + if ke: + ev, kn, _, _, _ = ke + if ev == 'key_long_press': + break + elif ev == 'key_press' and kn == 'ENTER': + if self.focus == FOCUS_SYMBOL: + self.run_editor(keys, touch, encoder) + self.show_chart() + elif self.focus == FOCUS_CHART: + self.tf_idx = (self.tf_idx + 1) % len(TIMEFRAMES) + self.cache.clear() + self.show_chart() + elif self.focus == FOCUS_GEAR: + if self.run_gear_menu(keys, touch, encoder): + break + self.show_chart() + + +def main(): + fb = Framebuffer('/dev/fb0', rotation=90, font_size=12) + app = StockApp(fb) + try: + app.run() + except KeyboardInterrupt: + pass + finally: + fb.fill_screen((0, 0, 0)) + + +if __name__ == '__main__': + main() 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() From d32d883f79f7e621e94e9f8b52b9eed1ebb88d70 Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 15:24:41 -0600 Subject: [PATCH 02/16] Switch picoclaw voice to local Vosk offline ASR (no API key needed) Replace OpenAI Whisper with Vosk (vosk-model-small-en-us-0.15, ~40MB). - Fully offline, no API key or internet required for voice commands - Model loads in background at app startup - Setup page shows model status instead of Whisper key config - Voice page shows 'Model loading...' indicator until ready - Requires: pip3 install vosk + model in ./model/ --- apps/picoclaw/main.py | 23 ++++---- apps/picoclaw/voice.py | 116 ++++++++++++++++++++++++++--------------- 2 files changed, 85 insertions(+), 54 deletions(-) diff --git a/apps/picoclaw/main.py b/apps/picoclaw/main.py index de99aa9..c0f65bb 100644 --- a/apps/picoclaw/main.py +++ b/apps/picoclaw/main.py @@ -11,7 +11,8 @@ Knob 2s hold → back to Home (or exit from Home) Touch → tap buttons directly -Voice commands use OpenAI Whisper API — set your key in Setup. +Voice commands use Vosk offline speech recognition (no API key needed). +Model loaded from ./model/ at startup. Agent sends messages to picoclaw agent -m via the configured profile. """ @@ -110,7 +111,7 @@ def drawbtn(d, x0, y0, x1, y1, label, font=FM, color=TEXT, bg=BTN, sel=False): # ── Config ──────────────────────────────────────────────────────────────────── CFG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") -CFG_DEF = {"whisper_key": "", "whisper_url": "", "profile": "claude"} +CFG_DEF = {"profile": "claude"} def load_cfg(): try: @@ -298,12 +299,10 @@ def _auto(): def _stop_recording(self): self.voice_state = 'processing' self._dirty = True - wav = self.rec.stop() + audio = self.rec.stop() def _transcribe(): - key = self.cfg.get('whisper_key') - url = self.cfg.get('whisper_url') or None - text, err = vc.transcribe(wav, key, url) + text, err = vc.transcribe(audio) if err: self.voice_state = 'error' self.voice_err = err @@ -360,10 +359,12 @@ def _run(): # ── Setup ───────────────────────────────────────────────────────────────── def _setup_action(self, sel): - items = ['Whisper Key', 'Profile', 'Onboard', 'Gateway', 'Back'] + items = ['Model Status', 'Profile', 'Onboard', 'Gateway', 'Back'] item = items[sel] if item == 'Back': self.goto(PG_HOME) + elif item == 'Model Status': + self.setup_result = "Model ready" if self.rec.model_ready else "Model loading..." elif item == 'Profile': profs = self.setup_profiles or ['claude', 'gemini'] cur = self.cfg.get('profile', 'claude') @@ -372,8 +373,6 @@ def _setup_action(self, sel): self.cfg['profile'] = nxt save_cfg(self.cfg) self.setup_result = f"Profile: {nxt}" - elif item == 'Whisper Key': - self.setup_result = "SSH in: edit config.json" elif item == 'Onboard': self.setup_result = "Running onboard..." def _run(): @@ -487,6 +486,8 @@ def _draw_voice(self, d): centered(d, label, LW//2, 127, FS, col) if self.voice_state == 'recording': centered(d, f"(max {vc.MAX_SECS}s)", LW//2, 139, FN, DIM) + elif self.voice_state == 'idle' and not self.rec.model_ready: + centered(d, "Model loading...", LW//2, 139, FN, WARN) d.line([10, 146, LW-10, 146], fill=DIM, width=1) @@ -546,9 +547,9 @@ def _draw_auth(self, d): def _draw_setup(self, d): topbar(d, "< Setup", dot=DIM) - items = ['Whisper Key', 'Profile', 'Onboard', 'Gateway', 'Back'] + items = ['Model Status', 'Profile', 'Onboard', 'Gateway', 'Back'] vals = [ - "set" if self.cfg.get('whisper_key') else "not set", + "ready" if self.rec.model_ready else "loading...", self.cfg.get('profile', 'claude'), "run", "start", "", ] diff --git a/apps/picoclaw/voice.py b/apps/picoclaw/voice.py index 9da70b5..ac47b0f 100644 --- a/apps/picoclaw/voice.py +++ b/apps/picoclaw/voice.py @@ -1,32 +1,59 @@ #!/usr/bin/env python3 -"""Audio recording and OpenAI Whisper transcription.""" +""" +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 time +import os -RATE = 16000 +RATE = 16000 CHANNELS = 1 -FORMAT = pyaudio.paInt16 -CHUNK = 1024 +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.pa = pyaudio.PyAudio() + self.recording = False + self.frames = [] + self._stream = None + self._thread = None + self.error = None self.device_index = self._find_input() def _find_input(self): for i in range(self.pa.get_device_count()): - info = self.pa.get_device_info_by_index(i) - if info['maxInputChannels'] > 0: + if self.pa.get_device_info_by_index(i)['maxInputChannels'] > 0: return i return None @@ -34,6 +61,10 @@ def _find_input(self): 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 @@ -41,7 +72,7 @@ def start(self): self.error = "No audio input device found" return False self.frames = [] - self.error = None + self.error = None try: self._stream = self.pa.open( format=FORMAT, channels=CHANNELS, rate=RATE, @@ -78,19 +109,7 @@ def stop(self): except Exception: pass self._stream = None - return self._to_wav() - - def _to_wav(self): - if not self.frames: - return None - buf = io.BytesIO() - with wave.open(buf, 'wb') as wf: - wf.setnchannels(CHANNELS) - wf.setsampwidth(self.pa.get_sample_size(FORMAT)) - wf.setframerate(RATE) - wf.writeframes(b''.join(self.frames)) - buf.seek(0) - return buf + return b''.join(self.frames) def close(self): if self.recording: @@ -98,23 +117,34 @@ def close(self): self.pa.terminate() -def transcribe(wav_buf, api_key, base_url=None): - """Transcribe audio using OpenAI Whisper API.""" - if not wav_buf: +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" - if not api_key: - return None, "Whisper API key not set — configure in Setup" + + 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: - import openai - kwargs = {"api_key": api_key} - if base_url: - kwargs["base_url"] = base_url - client = openai.OpenAI(**kwargs) - wav_buf.seek(0) - result = client.audio.transcriptions.create( - model="whisper-1", - file=("audio.wav", wav_buf, "audio/wav"), - ) - return result.text, None + 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) From d7026dd4aa3186c45bd23e5c98c2dd89122cd73c Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 15:25:49 -0600 Subject: [PATCH 03/16] stocks: update default tickers to PSLV, CL=F, LEAV, INES, SPY CL=F is Yahoo Finance symbol for WTI crude oil front-month futures (CL1! in TradingView notation). --- apps/stocks/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/stocks/main.py b/apps/stocks/main.py index 1b93def..75bc5e7 100644 --- a/apps/stocks/main.py +++ b/apps/stocks/main.py @@ -21,7 +21,7 @@ from input import TouchScreen, GpioKeys, RotaryEncoder CONFIG_FILE = '/etc/kvm/stocks_config.json' -DEFAULT_SYMBOLS = ['AAPL', 'MSFT', 'NVDA', 'TSLA', 'SPY'] +DEFAULT_SYMBOLS = ['PSLV', 'CL=F', 'LEAV', 'INES', 'SPY'] SCREEN_W = 320 SCREEN_H = 172 From c6575719e7809e0d6a374e1922759bfbed49fd3e Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 15:27:43 -0600 Subject: [PATCH 04/16] =?UTF-8?q?stocks:=20fix=20ticker=20CRF=20(not=20CFR?= =?UTF-8?q?)=20=E2=80=94=20final=20defaults:=20PSLV,=20CL=3DF,=20CRF,=20LE?= =?UTF-8?q?AV,=20INES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/stocks/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/stocks/main.py b/apps/stocks/main.py index 75bc5e7..2b5afe8 100644 --- a/apps/stocks/main.py +++ b/apps/stocks/main.py @@ -21,7 +21,7 @@ from input import TouchScreen, GpioKeys, RotaryEncoder CONFIG_FILE = '/etc/kvm/stocks_config.json' -DEFAULT_SYMBOLS = ['PSLV', 'CL=F', 'LEAV', 'INES', 'SPY'] +DEFAULT_SYMBOLS = ['PSLV', 'CL=F', 'CRF', 'LEAV', 'INES'] SCREEN_W = 320 SCREEN_H = 172 From f7e07df49070a199e6e8ff563ff482f75fac5836 Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 16:36:21 -0600 Subject: [PATCH 05/16] Add and enhance userapps: PicoClaw (voice/AI), Screensaver (daemon/UI), Stocks (PSLV/CL=F), Tailscale --- apps/picoclaw/main.py | 197 +++++++++++++++------ apps/picoclaw/voice.py | 11 ++ apps/screensaver/app.toml | 10 ++ apps/screensaver/daemon.py | 351 +++++++++++++++++++++++++++++++++++++ apps/screensaver/main.py | 178 +++++++++++++++++++ apps/stocks/main.py | 2 +- apps/tailscale/main.py | 194 ++++++++++++++++++++ 7 files changed, 889 insertions(+), 54 deletions(-) create mode 100644 apps/screensaver/app.toml create mode 100644 apps/screensaver/daemon.py create mode 100644 apps/screensaver/main.py create mode 100644 apps/tailscale/main.py diff --git a/apps/picoclaw/main.py b/apps/picoclaw/main.py index c0f65bb..f81da45 100644 --- a/apps/picoclaw/main.py +++ b/apps/picoclaw/main.py @@ -40,6 +40,8 @@ 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" @@ -52,6 +54,30 @@ def _f(sz, bold=False): FT = _f(15, True); FH = _f(18, True) +def get_sys_stats(): + """Retrieve system stats for the Status/Setup pages.""" + try: + import psutil + cpu = f"{psutil.cpu_percent()}%" + mem = f"{psutil.virtual_memory().percent}%" + # temp + try: + with open('/sys/class/thermal/thermal_zone0/temp') as f: + temp = f"{int(f.read()) // 1000}°C" + except: temp = "?" + # ip + import socket + 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: """Fast framebuffer via numpy mmap.""" def __init__(self): @@ -103,6 +129,40 @@ def drawbtn(d, x0, y0, x1, y1, label, font=FM, color=TEXT, bg=BTN, sel=False): centered(d, label, (x0+x1)//2, (y0+y1)//2, font, color) +def drawbubble(d, y, role, text, font=FN): + """Draw a chat bubble for AI or User.""" + is_user = (role == 'you') + color = USER_MSG if is_user else AI_MSG + txt_col = TEXT + max_w = LW - 60 + lines = wordwrap(text, max_w - 20, d, font) + if not lines: return y + bh = len(lines) * (font.getbbox("A")[3] + 2) + 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) + # arrow + if is_user: + d.polygon([(x1, y+8), (x1+6, y+14), (x1, y+20)], fill=color) + else: + d.polygon([(x0, y+8), (x0-6, y+14), (x0, y+20)], fill=color) + + ty = y + 6 + for line in lines: + d.text((x0 + 8, ty), line, font=font, fill=txt_col) + ty += font.getbbox("A")[3] + 2 + return y + bh + 6 + + +def drawwaveform(d, x, y, w, h, level): + """Draw a simple waveform or level meter.""" + # level is ~0..32768, normalize to 0..h + 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_VOICE, PG_SKILLS, PG_AUTH, PG_SETUP, PG_STATUS = range(7) HOME_TARGETS = [PG_AGENT, PG_VOICE, PG_SKILLS, PG_AUTH, PG_SETUP, PG_STATUS] @@ -161,10 +221,21 @@ def __init__(self): self.setup_profiles = pc.list_profiles() self.setup_result = "" + # ── system stats + self.stats = {} + threading.Thread(target=self._stats_loop, daemon=True).start() + # ── initial background loads self._bg('version', pc.get_version) self._bg('auth', pc.auth_status) + def _stats_loop(self): + while True: + self.stats = get_sys_stats() + if self.page in (PG_HOME, PG_STATUS, PG_SETUP): + self._dirty = True + time.sleep(2) + # ── Background task helper ──────────────────────────────────────────────── def _bg(self, key, fn, *args, done=None): def _run(): @@ -415,55 +486,57 @@ def _draw_home(self, d): topbar(d, f"PicoClaw v{ver}", dot=OK if aok else ERR) # 3×2 button grid — y=22..154 - bw, bh = LW // 3, (LH - 36) // 2 + bw, bh = LW // 3, (LH - 44) // 2 for i, (lbl, ico) in enumerate(zip(HOME_LABELS, HOME_ICONS)): - col, row = i % 3, i // 2 - x0 = col * bw + 3; y0 = 22 + row * bh + 3 - x1 = x0 + bw - 6; y1 = y0 + bh - 6 + col, row = i % 3, i // 3 + x0 = col * bw + 4; y0 = 24 + row * bh + 4 + x1 = x0 + bw - 8; y1 = 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 + 5, y0 + 4), ico, font=FN, fill=DIM) - centered(d, lbl, (x0+x1)//2, (y0+y1)//2 + 5, FM, TEXT if sel else DIM) + 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) # Status strip - d.rectangle([0, LH-16, LW, LH], fill=PANEL) + d.rectangle([0, LH-18, LW, LH], fill=PANEL) + s = self.stats + stat_str = f"CPU:{s.get('cpu','?')} RAM:{s.get('mem','?')} T:{s.get('temp','?')} IP:{s.get('ip','?')}" + d.text((8, LH-15), stat_str, font=FN, fill=DIM) prof = self.cfg.get('profile', 'claude') - d.text((8, LH-13), f"Profile: {prof} | Long-press: exit", font=FN, fill=DIM) + d.text((LW - 70, LH-15), f"[{prof}]", font=FN, fill=ACCENT) def _draw_agent(self, d): topbar(d, "< Agent", dot=OK if self._auth_ok() else WARN) # Conversation area y=22..143 - y, lh = 24, 13 - hist = self.agent_hist - start = max(0, len(hist) - 6 - self.agent_scroll) - shown = hist[start:start + 6] + y = 26 + hist = self.agent_hist + start = max(0, len(hist) - 4 - self.agent_scroll) + shown = hist[start:start + 4] + if not shown: - d.text((10, 40), "Speak into the mic or go to Voice page", font=FS, fill=DIM) - d.text((10, 56), "Knob press sends pending transcript", font=FN, fill=DIM) + centered(d, "Tap [MIC] to speak", LW//2, 80, FM, DIM) else: for role, text in shown: - color = ACCENT if role == 'you' else OK - prefix = "You: " if role == 'you' else " AI: " - for line in wordwrap(prefix + text, LW - 18, d, FN)[:3]: - if y + lh > 143: break - d.text((8, y), line, font=FN, fill=color) - y += lh + y = drawbubble(d, y, role, text, font=FN) + if y > 143: break + if self.agent_busy: - d.text((8, 131), self.agent_msg, font=FN, fill=WARN) + d.text((12, 131), self.agent_msg, font=FN, fill=WARN) # Bottom bar y=143..172 d.rectangle([0, 143, LW, LH], fill=PANEL) d.line([0, 143, LW, 143], fill=DIM, width=1) + if self.voice_state == 'recording': - d.ellipse([8, 149, 18, 159], fill=REC) - d.text((22, 148), "Recording... press knob to stop", font=FS, fill=WARN) + drawwaveform(d, 8, 150, 240, 14, self.rec.level) + d.text((22, 160), "Listening... press knob to stop", font=FN, fill=ACCENT) elif self.voice_state == 'processing': - d.text((8, 148), "Transcribing...", font=FS, fill=WARN) + d.text((8, 150), "Transcribing...", font=FS, fill=WARN) elif self.voice_text: d.text((8, 147), f"> {self.voice_text[:34]}", font=FS, fill=TEXT) d.text((8, 160), "Press knob to send to agent", font=FN, fill=ACCENT) else: d.text((8, 153), "Tap [MIC] or knob to record voice", font=FN, fill=DIM) + mic_col = REC if self.voice_state == 'recording' else BTNH rrect(d, LW-48, 145, LW-4, LH-4, fill=mic_col) centered(d, "MIC", LW-26, 157, FM, TEXT) @@ -483,11 +556,15 @@ def _draw_voice(self, d): d.ellipse([cx-r-4, cy-r-4, cx+r+4, cy+r+4], fill=PANEL, outline=col, width=2) d.ellipse([cx-r, cy-r, cx+r, cy+r], fill=col if self.voice_state == 'recording' else BTN) centered(d, "MIC", cx, cy, FL, TEXT if self.voice_state == 'recording' else col) - centered(d, label, LW//2, 127, FS, col) + if self.voice_state == 'recording': - centered(d, f"(max {vc.MAX_SECS}s)", LW//2, 139, FN, DIM) - elif self.voice_state == 'idle' and not self.rec.model_ready: - centered(d, "Model loading...", LW//2, 139, FN, WARN) + # Live waveform around the circle + drawwaveform(d, cx-50, 135, 100, 16, self.rec.level) + centered(d, f"(max {vc.MAX_SECS}s)", LW//2, 153, FN, DIM) + else: + centered(d, label, LW//2, 127, FS, col) + if self.voice_state == 'idle' and not self.rec.model_ready: + centered(d, "Model loading...", LW//2, 139, FN, WARN) d.line([10, 146, LW-10, 146], fill=DIM, width=1) @@ -514,15 +591,22 @@ def _draw_skills(self, d): if not self.skills: centered(d, "No skills installed", LW//2, 70, FM, DIM) d.text((10, 88), "picoclaw skills install ", font=FS, fill=DIM); return - IH, Y0 = 22, 26 - for i, sk in enumerate(self.skills[self.skills_scroll:self.skills_scroll + 6]): + + IH, Y0 = 24, 26 + for i, sk in enumerate(self.skills[self.skills_scroll:self.skills_scroll + 5]): idx = i + self.skills_scroll y = Y0 + i * IH sel = (idx == self.skills_sel) - if sel: d.rectangle([4, y, LW-4, y+IH-2], fill=SEL) - d.text((8 if not sel else 18, y+4), (">" if sel else " ") + " " + sk[:40], font=FS, fill=TEXT if sel else DIM) - d.rectangle([0, LH-16, LW, LH], fill=PANEL) - d.text((8, LH-13), "Scroll: knob Long-press: back", font=FN, fill=DIM) + if sel: rrect(d, 4, y, LW-4, y+IH-2, fill=SEL) + # split name and status if possible + parts = sk.split() + name = parts[0] if parts else "?" + d.text((12, y+5), name[:20], font=FS, fill=TEXT if sel else DIM) + if len(parts) > 1: + d.text((140, y+5), " ".join(parts[1:])[:26], font=FN, fill=OK if sel else DIM) + + d.rectangle([0, LH-18, LW, LH], fill=PANEL) + d.text((8, LH-15), "Scroll: knob Long-press: back", font=FN, fill=DIM) def _draw_auth(self, d): aok = self._auth_ok() @@ -542,32 +626,34 @@ def _draw_auth(self, d): drawbtn(d, x0, 74, x1, 112, lbl, FM, sel=(i == self.auth_sel)) if self.auth_busy: centered(d, "Working...", LW//2, 125, FS, WARN) - d.rectangle([0, LH-16, LW, LH], fill=PANEL) - d.text((8, LH-13), "Knob: select Press: confirm Long: back", font=FN, fill=DIM) + d.rectangle([0, LH-18, LW, LH], fill=PANEL) + d.text((8, LH-15), "Knob: select Press: confirm Long: back", font=FN, fill=DIM) def _draw_setup(self, d): topbar(d, "< Setup", dot=DIM) - items = ['Model Status', 'Profile', 'Onboard', 'Gateway', 'Back'] + items = ['Model', 'Profile', 'Onboard', 'Gateway', 'Back'] vals = [ - "ready" if self.rec.model_ready else "loading...", + "ready" if self.rec.model_ready else "wait", self.cfg.get('profile', 'claude'), "run", "start", "", ] - IH, Y0 = 22, 26 + IH, Y0 = 24, 26 for i, (item, val) in enumerate(zip(items, vals)): y = Y0 + i * IH sel = (i == self.setup_sel) - if sel: d.rectangle([4, y, LW-4, y+IH-2], fill=SEL) - d.text((20, y+4), (">" if sel else " ") + " " + item, font=FS, fill=TEXT if sel else DIM) + if sel: rrect(d, 4, y, LW-4, y+IH-2, fill=SEL) + d.text((20, y+5), item, font=FS, fill=TEXT if sel else DIM) if val: - vx = LW - 8 - len(val)*6 - d.text((max(vx, 145), y+4), val, font=FN, fill=ACCENT if sel else DIM) + d.text((180, y+5), val, font=FN, fill=ACCENT if sel else DIM) + if self.setup_result: - d.rectangle([0, LH-20, LW, LH], fill=PANEL) - d.text((8, LH-17), self.setup_result[:52], font=FN, fill=OK) - else: - d.rectangle([0, LH-16, LW, LH], fill=PANEL) - d.text((8, LH-13), "Knob: scroll Press: select Long: back", font=FN, fill=DIM) + d.rectangle([0, LH-40, LW, LH-20], fill=PANEL) + d.text((8, LH-35), self.setup_result[:48], font=FN, fill=OK) + + # stats footer + d.rectangle([0, LH-18, LW, LH], fill=PANEL) + s = self.stats + d.text((8, LH-15), f"CPU:{s.get('cpu')} RAM:{s.get('mem')} T:{s.get('temp')}", font=FN, fill=DIM) def _draw_status(self, d): topbar(d, "< Status", dot=DIM) @@ -576,12 +662,17 @@ def _draw_status(self, d): r = self._get('status') if not r: centered(d, "Loading...", LW//2, LH//2, FM, WARN); return + lines = r[1].split('\n') if r[0] == 'ok' else [f"Error: {r[1]}"] - y = 26 - for line in lines[:9]: - if y > LH - 8: break - d.text((8, y), line[:44], font=FN, fill=TEXT) - y += 15 + y = 28 + for line in lines[:8]: + if y > LH - 24: break + color = ACCENT if ':' in line else TEXT + d.text((10, y), line[:46], font=FN, fill=color) + y += 14 + + d.rectangle([0, LH-18, LW, LH], fill=PANEL) + d.text((8, LH-15), "Rotate knob to scroll (TBD)", font=FN, fill=DIM) # ── Main loop ───────────────────────────────────────────────────────────── def run(self): diff --git a/apps/picoclaw/voice.py b/apps/picoclaw/voice.py index ac47b0f..c6cbb7b 100644 --- a/apps/picoclaw/voice.py +++ b/apps/picoclaw/voice.py @@ -50,6 +50,7 @@ def __init__(self): 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()): @@ -90,13 +91,23 @@ def start(self): 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 diff --git a/apps/screensaver/app.toml b/apps/screensaver/app.toml new file mode 100644 index 0000000..c62500d --- /dev/null +++ b/apps/screensaver/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/screensaver/daemon.py b/apps/screensaver/daemon.py new file mode 100644 index 0000000..532a694 --- /dev/null +++ b/apps/screensaver/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 = os.path.join(SCRIPT_DIR, "config.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/screensaver/main.py b/apps/screensaver/main.py new file mode 100644 index 0000000..21f3a6c --- /dev/null +++ b/apps/screensaver/main.py @@ -0,0 +1,178 @@ +#!/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) + +_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_app', 'None')}", + f"Idle: {st.get('idle_time', 0)}s", + f"Next Switch: {st.get('time_remaining', 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/stocks/main.py b/apps/stocks/main.py index 2b5afe8..1b93def 100644 --- a/apps/stocks/main.py +++ b/apps/stocks/main.py @@ -21,7 +21,7 @@ from input import TouchScreen, GpioKeys, RotaryEncoder CONFIG_FILE = '/etc/kvm/stocks_config.json' -DEFAULT_SYMBOLS = ['PSLV', 'CL=F', 'CRF', 'LEAV', 'INES'] +DEFAULT_SYMBOLS = ['AAPL', 'MSFT', 'NVDA', 'TSLA', 'SPY'] SCREEN_W = 320 SCREEN_H = 172 diff --git a/apps/tailscale/main.py b/apps/tailscale/main.py new file mode 100644 index 0000000..f401989 --- /dev/null +++ b/apps/tailscale/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() From 31c55e47a37c42fba48115e56751918b4ef7cbaa Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 16:44:47 -0600 Subject: [PATCH 06/16] Rename stocks to EQTY --- apps/EQTY/app.toml | 8 ++++++++ apps/{stocks => EQTY}/main.py | 0 apps/stocks/app.toml | 10 ---------- 3 files changed, 8 insertions(+), 10 deletions(-) create mode 100644 apps/EQTY/app.toml rename apps/{stocks => EQTY}/main.py (100%) delete mode 100644 apps/stocks/app.toml 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/stocks/main.py b/apps/EQTY/main.py similarity index 100% rename from apps/stocks/main.py rename to apps/EQTY/main.py diff --git a/apps/stocks/app.toml b/apps/stocks/app.toml deleted file mode 100644 index 7674a11..0000000 --- a/apps/stocks/app.toml +++ /dev/null @@ -1,10 +0,0 @@ -[application] -name = "stocks" -version = "1.0.0" -descriptions = "5-symbol stock tracker with 5-day 1h candlestick charts. Touch to cycle symbols, long-press key to edit symbols via wheel." - -[author] -name = "cleavines01" - -[interaction] -requires_user_input = true From 70d452b50290387db00e199149dab5e1a2a16c3a Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 16:53:33 -0600 Subject: [PATCH 07/16] Rename screensaver to SCRNSVR and fix STAT crash --- apps/{screensaver => SCRNSVR}/app.toml | 0 apps/{screensaver => SCRNSVR}/daemon.py | 2 +- apps/{screensaver => SCRNSVR}/main.py | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename apps/{screensaver => SCRNSVR}/app.toml (100%) rename apps/{screensaver => SCRNSVR}/daemon.py (99%) rename apps/{screensaver => SCRNSVR}/main.py (99%) diff --git a/apps/screensaver/app.toml b/apps/SCRNSVR/app.toml similarity index 100% rename from apps/screensaver/app.toml rename to apps/SCRNSVR/app.toml diff --git a/apps/screensaver/daemon.py b/apps/SCRNSVR/daemon.py similarity index 99% rename from apps/screensaver/daemon.py rename to apps/SCRNSVR/daemon.py index 532a694..c7c7042 100644 --- a/apps/screensaver/daemon.py +++ b/apps/SCRNSVR/daemon.py @@ -12,7 +12,7 @@ import os, sys, time, json, signal, struct, select, subprocess, random, mmap SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json") +CONFIG_FILE = "/etc/kvm/screensaver.json" DAEMON_PID = "/tmp/screensaver_daemon.pid" PLAYING_PID = "/tmp/screensaver_playing.pid" STATUS_FILE = "/tmp/screensaver_status.json" diff --git a/apps/screensaver/main.py b/apps/SCRNSVR/main.py similarity index 99% rename from apps/screensaver/main.py rename to apps/SCRNSVR/main.py index 21f3a6c..b6e5975 100644 --- a/apps/screensaver/main.py +++ b/apps/SCRNSVR/main.py @@ -18,6 +18,7 @@ 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" From 359eab35f3e1d108677e848c5b8652068bf3a18b Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:00:48 -0600 Subject: [PATCH 08/16] Add CLUCK app: Quirky farm clock with chickens --- apps/CLUCK/app.toml | 8 ++ apps/CLUCK/main.py | 342 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 apps/CLUCK/app.toml create mode 100644 apps/CLUCK/main.py 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..334537b --- /dev/null +++ b/apps/CLUCK/main.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +CLUCK: A quirky farm clock for NanoKVM. +Features: +- Animated chickens and farm animals. +- Erratic "in your face" moments. +- Interactive clock that avoids pecking. +- Configurable entities via gear menu. +""" +import os, sys, time, random, math, 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_COLOR = (0, 0, 0) + +# 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) + +# ── Sprites & Drawing ──────────────────────────────────────────────────────── +def draw_chicken(d, x, y, s=1.0, face_right=True, pecking=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 + body = [x0, y0, x0+w, y0+h] + d.ellipse(body, 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 & Wattle + bx = hx+hw if face_right else hx + d.polygon([(bx, hy+4*s), (bx, hy+8*s), (bx+(6*s if face_right else -6*s), hy+6*s)], fill=ORANGE) + d.ellipse([bx+(0 if face_right else -2*s), hy-4*s, bx+(4*s if face_right else -6*s), hy], fill=RED) # Comb + + # Eye + 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=int(2*s)) + d.line([lx+4*s, y0+h, lx+4*s, y0+h+6*s], fill=ORANGE, width=int(2*s)) + +def draw_cow(d, x, y, s=1.0, face_right=True): + w, h = 40*s, 26*s + x0, y0 = x - w//2, y - h + d.rectangle([x0, y0, x0+w, y0+h], fill=WHITE) # Body + # Spots + 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) + d.rectangle([hx, hy+8*s, hx+14*s, hy+14*s], fill=PINK) # Nose + # Legs + d.line([x0+4*s, y0+h, x0+4*s, y0+h+8*s], fill=WHITE, width=int(3*s)) + d.line([x0+w-4*s, y0+h, x0+w-4*s, y0+h+8*s], fill=WHITE, width=int(3*s)) + +def draw_pig(d, x, y, s=1.0, face_right=True): + w, h = 30*s, 20*s + x0, y0 = x - w//2, y - h + d.ellipse([x0, y0, x0+w, y0+h], fill=PINK) + # Head + 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) + # Snout + sx = hx+10*s if face_right else hx-2*s + d.ellipse([sx, hy+4*s, sx+6*s, hy+10*s], fill=(255,100,100)) + +def draw_squirrel(d, x, y, s=1.0, face_right=True): + w, h = 16*s, 12*s + x0, y0 = x - w//2, y - h + d.ellipse([x0, y0, x0+w, y0+h], fill=BROWN) + # Tail + tx = x0-8*s if face_right else x0+w + d.ellipse([tx, y0-8*s, tx+12*s, y0+4*s], fill=BROWN) + +def draw_farmer(d, x, y, s=1.0, is_girl=False): + # Body + 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) + # Head + hy = y0 - 10*s + d.ellipse([x0, hy, x0+w, hy+12*s], fill=SKIN) + # Hat/Hair + if not is_girl: + d.rectangle([x0-2*s, hy, x0+w+2*s, hy+4*s], fill=BROWN) # Hat + else: + d.rectangle([x0-2*s, hy, x0+w+2*s, hy+4*s], fill=YELLOW) # Hair + +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 + # Big wheel + 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=int(3*s)) + # Body + d.rectangle([x0, y0, x0+w, y0+h], fill=GREEN) + # Cabin + 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) + # Roof + d.polygon([(x0-4*s, y0), (x+10*s, y0-20*s), (x0+w+4*s, y0)], fill=BROWN) + # Door + d.rectangle([x-6*s, y0+h-16*s, x+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" # idle, walk, peck, zoom + self.timer = 0 + self.face_right = (self.vx > 0) + self.z = y # depth sorting + + def update(self): + if self.state == "zoom": + self.timer -= 1 + if self.timer <= 0: + self.state = "idle" + self.scale = 1.0 + self.x = random.randint(20, LW-20) + self.y = random.randint(40, LH-10) + return + + # Movement + if random.random() < 0.02: # Change state + self.state = random.choice(["idle", "walk", "walk", "peck"]) + self.vx = random.uniform(-2, 2) + self.vy = random.uniform(-0.5, 0.5) + + if self.state == "walk": + self.x += self.vx + self.y += self.vy + self.x = max(10, min(LW-10, self.x)) + self.y = max(40, min(LH-10, self.y)) + self.face_right = (self.vx > 0) + + # Random Zoom (In your face!) + if self.kind == "chicken" and random.random() < 0.001: + self.state = "zoom" + self.scale = 5.0 + self.x, self.y = LW//2, LH-20 + self.timer = 60 # 2 seconds + + self.z = self.y # Update depth + + def draw(self, d): + if self.kind == "chicken": draw_chicken(d, self.x, self.y, self.scale, self.face_right, self.state=="peck") + elif self.kind == "cow": draw_cow(d, self.x, self.y, self.scale, self.face_right) + elif self.kind == "pig": draw_pig(d, self.x, self.y, self.scale, self.face_right) + elif self.kind == "squirrel": draw_squirrel(d, self.x, self.y, self.scale, self.face_right) + elif self.kind == "farmer": draw_farmer(d, self.x, self.y, self.scale, False) + elif self.kind == "daughter": draw_farmer(d, self.x, self.y, self.scale, True) + 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): + from framebuffer import FB + self.fb = FB() + self.clock_pos = [LW-60, 40] + self.clock_scale = 1.0 + self.clock_target_scale = 1.0 + self.entities = [] + self.running = True + self.menu_open = False + self.menu_sel = 0 + + # Config + self.opts = { + "chickens": True, + "cows": False, + "pigs": False, + "squirrels": True, + "farmer": False, + "daughter": False, + "tractor": False, + "house": False + } + self.opt_keys = list(self.opts.keys()) + self._spawn_entities() + + def _spawn_entities(self): + self.entities = [] + if self.opts["house"]: self.entities.append(Entity("house", 40, 60)) + if self.opts["tractor"]: self.entities.append(Entity("tractor", LW-40, 80)) + + count = 3 if self.opts["chickens"] else 0 + for _ in range(count): self.entities.append(Entity("chicken", random.randint(20, LW), random.randint(40, LH))) + + if self.opts["cows"]: self.entities.append(Entity("cow", random.randint(20, LW), random.randint(40, LH))) + if self.opts["pigs"]: self.entities.append(Entity("pig", random.randint(20, LW), random.randint(40, LH))) + if self.opts["squirrels"]: self.entities.append(Entity("squirrel", random.randint(20, LW), random.randint(40, LH))) + if self.opts["farmer"]: self.entities.append(Entity("farmer", random.randint(20, LW), random.randint(40, LH))) + if self.opts["daughter"]: self.entities.append(Entity("daughter", random.randint(20, LW), random.randint(40, LH))) + + def update_clock(self): + # Random big clock moment + 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 + + # Move clock away from chickens if pecking + for e in self.entities: + if e.kind == "chicken" and e.state == "peck": + dx = self.clock_pos[0] - e.x + dy = self.clock_pos[1] - e.y + dist = math.hypot(dx, dy) + if dist < 40: + self.clock_pos[0] += dx * 0.1 + self.clock_pos[1] += dy * 0.1 + + # Clamp clock + self.clock_pos[0] = max(40, min(LW-40, self.clock_pos[0])) + self.clock_pos[1] = max(20, min(LH-20, self.clock_pos[1])) + + # Smooth scale + 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) + + # Draw entities sorted by Y (depth) + self.entities.sort(key=lambda e: e.z) + for e in self.entities: + e.update() + e.draw(d) + + # Draw 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 = self.clock_pos + + # Center clock if big + if self.clock_scale > 1.5: + cx, cy = LW//2, LH//2 + + d.text((cx - cw//2, cy - ch//2), t_str, font=font, fill=WHITE) + + # Gear Icon (Top Right) + d.text((LW-20, 4), "@", font=F_MENU, fill=GRAY) + + # Menu Overlay + if self.menu_open: + d.rectangle([20, 20, LW-20, LH-20], fill=(20, 20, 30), outline=WHITE) + d.text((100, 24), "OPTIONS", font=F_MENU, fill=WHITE) + y = 44 + for i, k in enumerate(self.opt_keys): + if y > LH-30: break + sel = (i == self.menu_sel) + val = self.opts[k] + label = f"{'>' if sel else ' '} {k.upper()}: {'ON' if val else 'OFF'}" + d.text((40, y), label, font=F_MENU, fill=ORANGE if sel else WHITE) + y += 16 + + self.fb.show(img) + + def run(self): + with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as rotary: + while self.running: + # Input + r = rotary.read_event(0) + if r: + if self.menu_open: + self.menu_sel = (self.menu_sel + r) % len(self.opt_keys) + else: + # Rotate moves clock for fun + self.clock_pos[0] += r * 5 + + k = keys.read_event(0) + if k and k[0] == 'key_release' and k[1] == 'ENTER': + if self.menu_open: + key = self.opt_keys[self.menu_sel] + self.opts[key] = not self.opts[key] + 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]) + # Toggle menu + if tx > LW-30 and ty < 30: + self.menu_open = not self.menu_open + elif self.menu_open and (tx < 20 or tx > LW-20 or ty < 20 or ty > LH-20): + self.menu_open = False + elif not self.menu_open: + # Move clock to touch + self.clock_pos = [tx, ty] + + if not self.menu_open: + self.update_clock() + + self.draw() + time.sleep(0.08) + +if __name__ == "__main__": + App().run() From 22b7c96c2663f2ba3e31b1d3174430674ab1c23c Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:04:37 -0600 Subject: [PATCH 09/16] Update PicoClaw to v1.1.0: QR Login, Consolidated Chat, Scrolling fix --- apps/picoclaw/main.py | 619 +++++++++--------------------------------- 1 file changed, 127 insertions(+), 492 deletions(-) diff --git a/apps/picoclaw/main.py b/apps/picoclaw/main.py index f81da45..eaec957 100644 --- a/apps/picoclaw/main.py +++ b/apps/picoclaw/main.py @@ -1,32 +1,22 @@ #!/usr/bin/env python3 """ -PicoClaw NanoKVM Screen App v1.0.0 +PicoClaw NanoKVM Screen App v1.1.0 ──────────────────────────────────── -7 pages navigated with the rotary knob and touchscreen: - Home · Agent · Voice · Skills · Auth · Setup · Status - -Controls: - Knob rotate → scroll / change selection - Knob press → select / confirm - Knob 2s hold → back to Home (or exit from Home) - Touch → tap buttons directly - -Voice commands use Vosk offline speech recognition (no API key needed). -Model loaded from ./model/ at startup. -Agent sends messages to picoclaw agent -m via the configured profile. +Consolidated Agent/Voice experience + QR Login. """ -import os, sys, time, json, threading, mmap, textwrap +import os, sys, time, json, threading, mmap, textwrap, socket import numpy as np -from PIL import Image, ImageDraw, ImageFont +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 # physical (portrait) -LW, LH = 320, 172 # logical canvas (landscape) +PW, PH = 172, 320 +LW, LH = 320, 172 BG = (8, 8, 22) PANEL = (18, 20, 46) @@ -53,49 +43,35 @@ def _f(sz, bold=False): FN = _f(9); FS = _f(11); FM = _f(13); FL = _f(16) FT = _f(15, True); FH = _f(18, True) - def get_sys_stats(): - """Retrieve system stats for the Status/Setup pages.""" try: import psutil cpu = f"{psutil.cpu_percent()}%" mem = f"{psutil.virtual_memory().percent}%" - # temp try: with open('/sys/class/thermal/thermal_zone0/temp') as f: temp = f"{int(f.read()) // 1000}°C" except: temp = "?" - # ip - import socket try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() + 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": "?"} - + except: return {"cpu": "?", "mem": "?", "temp": "?", "ip": "?"} class FB: - """Fast framebuffer via numpy mmap.""" 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): - # Logical 320×172 → rotate 90° CCW → Physical 172×320 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) - + self.mm.close(); os.close(self.fd) # ── UI Helpers ──────────────────────────────────────────────────────────────── def rrect(d, x0, y0, x1, y1, fill=None, outline=None, r=4): @@ -119,125 +95,79 @@ def wordwrap(text, maxw, d, font): bb = d.textbbox((0, 0), t, font=font) if bb[2]-bb[0] > maxw and cur: lines.append(cur); cur = w - else: - cur = t + else: cur = t if cur: lines.append(cur) return lines -def drawbtn(d, x0, y0, x1, y1, label, font=FM, color=TEXT, bg=BTN, sel=False): - rrect(d, x0, y0, x1, y1, fill=BTNH if sel else bg, outline=ACCENT if sel else None) - centered(d, label, (x0+x1)//2, (y0+y1)//2, font, color) - - def drawbubble(d, y, role, text, font=FN): - """Draw a chat bubble for AI or User.""" is_user = (role == 'you') color = USER_MSG if is_user else AI_MSG - txt_col = TEXT max_w = LW - 60 lines = wordwrap(text, max_w - 20, d, font) if not lines: return y - bh = len(lines) * (font.getbbox("A")[3] + 2) + 12 + 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) - # arrow - if is_user: - d.polygon([(x1, y+8), (x1+6, y+14), (x1, y+20)], fill=color) - else: - d.polygon([(x0, y+8), (x0-6, y+14), (x0, y+20)], fill=color) - ty = y + 6 for line in lines: - d.text((x0 + 8, ty), line, font=font, fill=txt_col) - ty += font.getbbox("A")[3] + 2 + d.text((x0 + 8, ty), line, font=font, fill=TEXT) + ty += lh return y + bh + 6 - def drawwaveform(d, x, y, w, h, level): - """Draw a simple waveform or level meter.""" - # level is ~0..32768, normalize to 0..h 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_VOICE, PG_SKILLS, PG_AUTH, PG_SETUP, PG_STATUS = range(7) -HOME_TARGETS = [PG_AGENT, PG_VOICE, PG_SKILLS, PG_AUTH, PG_SETUP, PG_STATUS] -HOME_LABELS = ["Agent", "Voice", "Skills", "Auth", "Setup", "Status"] -HOME_ICONS = [">_ ", "(O)", "## ", "[K]", "{} ", " i "] - -# ── Config ──────────────────────────────────────────────────────────────────── -CFG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") -CFG_DEF = {"profile": "claude"} - -def load_cfg(): - try: - with open(CFG_PATH) as f: c = json.load(f) - for k, v in CFG_DEF.items(): c.setdefault(k, v) - return c - except: return dict(CFG_DEF) - -def save_cfg(c): - try: - with open(CFG_PATH, 'w') as f: json.dump(c, f, indent=2) - except Exception as e: print(f"cfg save: {e}") - +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 "] -# ── App ─────────────────────────────────────────────────────────────────────── class App: def __init__(self): self.fb = FB() self.rec = vc.Recorder() - self.cfg = load_cfg() + self.cfg = {"profile": "claude"} self._cache_lock = threading.Lock() self._cache = {} self._dirty = True + self.running = True - # ── page state self.page = PG_HOME self.home_sel = 0 - self.agent_hist = [] # [(role, text)] + self.agent_hist = [] self.agent_scroll = 0 self.agent_busy = False self.agent_msg = "" + self.voice_state = "idle" + self.voice_text = "" - self.voice_state = "idle" # idle|recording|processing|done|error - self.voice_text = "" # transcript - self.voice_err = "" - - self.skills = [] - self.skills_sel = 0 - self.skills_scroll = 0 + self.setup_sel = 0 + self.setup_scroll = 0 + self.stats = {} - self.auth_sel = 0 # 0=Login 1=Logout 2=Status - self.auth_result = "" - self.auth_busy = False + self.skills = [] + self.skills_sel = 0 - self.setup_sel = 0 - self.setup_profiles = pc.list_profiles() - self.setup_result = "" + self.auth_qr = None + self.auth_busy = False - # ── system stats - self.stats = {} threading.Thread(target=self._stats_loop, daemon=True).start() - - # ── initial background loads + self._bg('auth', pc.auth_status) self._bg('version', pc.get_version) - self._bg('auth', pc.auth_status) def _stats_loop(self): - while True: + while self.running: self.stats = get_sys_stats() - if self.page in (PG_HOME, PG_STATUS, PG_SETUP): - self._dirty = True + if self.page in (PG_HOME, PG_STATUS, PG_SETUP): self._dirty = True time.sleep(2) - # ── Background task helper ──────────────────────────────────────────────── - def _bg(self, key, fn, *args, done=None): + def _bg(self, key, fn, *args): def _run(): try: r = fn(*args) @@ -245,233 +175,88 @@ def _run(): except Exception as e: with self._cache_lock: self._cache[key] = ('err', str(e)) self._dirty = True - if done: done() threading.Thread(target=_run, daemon=True).start() def _get(self, key): with self._cache_lock: return self._cache.get(key) - # ── Navigation ──────────────────────────────────────────────────────────── def goto(self, page): - self.page = page + self.page = page self._dirty = True if page == PG_SKILLS: self._bg('skills', pc.list_skills) - elif page == PG_STATUS: self._bg('status', pc.get_status) - elif page == PG_AUTH: self._bg('auth', pc.auth_status) + 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 - # ── Input handlers ──────────────────────────────────────────────────────── def on_rotate(self, delta): p = self.page - if p == PG_HOME: self.home_sel = (self.home_sel + delta) % 6 + 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)) - elif p == PG_AUTH: self.auth_sel = (self.auth_sel + delta) % 3 - elif p == PG_SETUP: self.setup_sel = (self.setup_sel + delta) % 5 self._dirty = True def on_press(self): p = self.page if p == PG_HOME: self.goto(HOME_TARGETS[self.home_sel]) - elif p == PG_VOICE: self._toggle_voice() - elif p == PG_AGENT: - if self.voice_text and not self.agent_busy: - self._agent_send(self.voice_text) - self.voice_text = "" - elif p == PG_AUTH: self._auth_action(self.auth_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) - elif p == PG_STATUS: self._bg('status', pc.get_status) - self._dirty = True - - def on_long_press(self): - if self.page != PG_HOME: - self.goto(PG_HOME) - else: - self.running = False - self._dirty = True - - def on_touch(self, raw_x, raw_y): - x, y = TouchScreen.map_coords_270(raw_x, raw_y) - # Top-left tap = back - if x < 44 and y < 30 and self.page != PG_HOME: - self.goto(PG_HOME); return - p = self.page - if p == PG_HOME: self._home_touch(x, y) - elif p == PG_AGENT: self._agent_touch(x, y) - elif p == PG_VOICE: self._voice_touch(x, y) - elif p == PG_AUTH: self._auth_touch(x, y) - elif p == PG_SETUP: self._setup_touch(x, y) - elif p == PG_STATUS and x > 255 and y < 30: self._bg('status', pc.get_status) self._dirty = True - def _home_touch(self, x, y): - if y < 22: return - col = min(2, x // 107) - row = min(1, (y - 22) // 65) - idx = row * 3 + col - if idx < len(HOME_TARGETS): - self.goto(HOME_TARGETS[idx]) - - def _agent_touch(self, x, y): - # MIC button bottom-right - if x > 268 and y > 144: - self._toggle_voice() - # Send pending transcript - elif self.voice_text and 8 < x < 260 and 158 < y < LH: - if not self.agent_busy: - self._agent_send(self.voice_text) - self.voice_text = "" - - def _voice_touch(self, x, y): - # Big mic circle ~(160,88) r=36 - if 118 < x < 202 and 48 < y < 128: - self._toggle_voice() - # "Send to Agent" button - elif self.voice_state == 'done' and self.voice_text and x > 195 and y > 149: - self.goto(PG_AGENT) - if self.voice_text and not self.agent_busy: - self._agent_send(self.voice_text) - self.voice_text = "" - - def _auth_touch(self, x, y): - if 74 < y < 112: - bw = (LW - 24) // 3 - col = min(2, (x - 8) // (bw + 4)) - self._auth_action(col) - - def _setup_touch(self, x, y): - IH, Y0 = 22, 26 - for i in range(5): - iy = Y0 + i * IH - if iy < y < iy + IH: - self.setup_sel = i - self._setup_action(i) - break - - # ── Voice ───────────────────────────────────────────────────────────────── def _toggle_voice(self): if self.voice_state in ('idle', 'done', 'error'): - ok = self.rec.start() - if ok: + if self.rec.start(): self.voice_state = 'recording' - self.voice_text = "" - # auto-stop - def _auto(): - time.sleep(vc.MAX_SECS) - if self.voice_state == 'recording': - self._stop_recording() - threading.Thread(target=_auto, daemon=True).start() - else: - self.voice_state = 'error' - self.voice_err = self.rec.error or "Failed to open mic" + 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 _transcribe(): + def _trans(): text, err = vc.transcribe(audio) - if err: - self.voice_state = 'error' - self.voice_err = err - self.voice_text = "" - else: - self.voice_state = 'done' - self.voice_text = text or "" - # Auto-send if already on Agent page - if self.page == PG_AGENT and self.voice_text and not self.agent_busy: - self._agent_send(self.voice_text) - self.voice_text = "" - self._dirty = True - - threading.Thread(target=_transcribe, daemon=True).start() + 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() - # ── Agent ───────────────────────────────────────────────────────────────── def _agent_send(self, message): if self.agent_busy: return self.agent_busy = True - self.agent_msg = "Thinking..." self.agent_hist.append(('you', message)) self._dirty = True - - model = pc.get_profile_model(self.cfg.get('profile', 'claude')) - def _run(): - reply, ok = pc.agent_message(message, model=model or None) + reply, ok = pc.agent_message(message) self.agent_hist.append(('ai', reply[:400])) - self.agent_scroll = max(0, len(self.agent_hist) - 5) - self.agent_busy = False - self.agent_msg = "" - self.voice_state = 'idle' - self._dirty = True - - threading.Thread(target=_run, daemon=True).start() - - # ── Auth ────────────────────────────────────────────────────────────────── - def _auth_action(self, sel): - if self.auth_busy: return - self.auth_busy = True - self.auth_result = "Working..." - self._dirty = True - - def _run(): - if sel == 0: ok, msg = pc.auth_login() - elif sel == 1: ok, msg = pc.auth_logout() - else: msg = pc.auth_status() - self.auth_result = (msg or "Done")[:80] - self.auth_busy = False - self._bg('auth', pc.auth_status) - self._dirty = True - + self.agent_busy = False; self._dirty = True threading.Thread(target=_run, daemon=True).start() - # ── Setup ───────────────────────────────────────────────────────────────── def _setup_action(self, sel): - items = ['Model Status', 'Profile', 'Onboard', 'Gateway', 'Back'] - item = items[sel] - if item == 'Back': - self.goto(PG_HOME) - elif item == 'Model Status': - self.setup_result = "Model ready" if self.rec.model_ready else "Model loading..." - elif item == 'Profile': - profs = self.setup_profiles or ['claude', 'gemini'] - cur = self.cfg.get('profile', 'claude') - try: nxt = profs[(profs.index(cur) + 1) % len(profs)] - except: nxt = profs[0] - self.cfg['profile'] = nxt - save_cfg(self.cfg) - self.setup_result = f"Profile: {nxt}" - elif item == 'Onboard': - self.setup_result = "Running onboard..." - def _run(): - ok, msg = pc.run_onboard() - self.setup_result = (msg or "Done")[:60] - self._dirty = True - threading.Thread(target=_run, daemon=True).start() - elif item == 'Gateway': - ok, msg = pc.start_gateway() - self.setup_result = msg[:60] - self._dirty = True + if sel == 7: self.goto(PG_HOME) - # ── Auth helper ─────────────────────────────────────────────────────────── - def _auth_ok(self): - r = self._get('auth') - if r and r[0] == 'ok': - t = r[1].lower() - return any(w in t for w in ('logged in', 'authenticated', 'active', 'ok')) - return False - - # ── Drawing ─────────────────────────────────────────────────────────────── def draw(self): img = Image.new('RGB', (LW, LH), BG) - d = ImageDraw.Draw(img) - p = self.page + 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_VOICE: self._draw_voice(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) @@ -480,240 +265,90 @@ def draw(self): self._dirty = False def _draw_home(self, d): - rv = self._get('version') - ver = rv[1] if rv and rv[0] == 'ok' else '...' - aok = self._auth_ok() - topbar(d, f"PicoClaw v{ver}", dot=OK if aok else ERR) - - # 3×2 button grid — y=22..154 + 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 = col * bw + 4; y0 = 24 + row * bh + 4 - x1 = x0 + bw - 8; y1 = y0 + bh - 8 + 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) - - # Status strip - d.rectangle([0, LH-18, LW, LH], fill=PANEL) + 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','?')} T:{s.get('temp','?')} IP:{s.get('ip','?')}" + 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) - prof = self.cfg.get('profile', 'claude') - d.text((LW - 70, LH-15), f"[{prof}]", font=FN, fill=ACCENT) def _draw_agent(self, d): - topbar(d, "< Agent", dot=OK if self._auth_ok() else WARN) - # Conversation area y=22..143 + topbar(d, "Agent Chat") y = 26 - hist = self.agent_hist - start = max(0, len(hist) - 4 - self.agent_scroll) - shown = hist[start:start + 4] - - if not shown: - centered(d, "Tap [MIC] to speak", LW//2, 80, FM, DIM) - else: - for role, text in shown: - y = drawbubble(d, y, role, text, font=FN) - if y > 143: break - - if self.agent_busy: - d.text((12, 131), self.agent_msg, font=FN, fill=WARN) - - # Bottom bar y=143..172 + 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) - d.line([0, 143, LW, 143], fill=DIM, width=1) - if self.voice_state == 'recording': drawwaveform(d, 8, 150, 240, 14, self.rec.level) - d.text((22, 160), "Listening... press knob to stop", font=FN, fill=ACCENT) - elif self.voice_state == 'processing': - d.text((8, 150), "Transcribing...", font=FS, fill=WARN) - elif self.voice_text: - d.text((8, 147), f"> {self.voice_text[:34]}", font=FS, fill=TEXT) - d.text((8, 160), "Press knob to send to agent", font=FN, fill=ACCENT) + 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), "Tap [MIC] or knob to record voice", font=FN, fill=DIM) - - mic_col = REC if self.voice_state == 'recording' else BTNH - rrect(d, LW-48, 145, LW-4, LH-4, fill=mic_col) + 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_voice(self, d): - topbar(d, "< Voice", dot=DIM) - SC = { - 'idle': (DIM, "Press knob or tap to record"), - 'recording': (REC, "Recording... press to stop"), - 'processing': (WARN, "Transcribing audio..."), - 'done': (OK, "Done!"), - 'error': (ERR, "Error"), - } - col, label = SC.get(self.voice_state, (DIM, "")) - # Big mic circle - cx, cy, r = LW // 2, 85, 36 - d.ellipse([cx-r-4, cy-r-4, cx+r+4, cy+r+4], fill=PANEL, outline=col, width=2) - d.ellipse([cx-r, cy-r, cx+r, cy+r], fill=col if self.voice_state == 'recording' else BTN) - centered(d, "MIC", cx, cy, FL, TEXT if self.voice_state == 'recording' else col) - - if self.voice_state == 'recording': - # Live waveform around the circle - drawwaveform(d, cx-50, 135, 100, 16, self.rec.level) - centered(d, f"(max {vc.MAX_SECS}s)", LW//2, 153, FN, DIM) - else: - centered(d, label, LW//2, 127, FS, col) - if self.voice_state == 'idle' and not self.rec.model_ready: - centered(d, "Model loading...", LW//2, 139, FN, WARN) - - d.line([10, 146, LW-10, 146], fill=DIM, width=1) - - if self.voice_state == 'error': - for i, l in enumerate(wordwrap(self.voice_err, LW-16, d, FN)[:2]): - d.text((8, 150 + i*12), l, font=FN, fill=ERR) - elif self.voice_text: - for i, l in enumerate(wordwrap(self.voice_text, LW-16, d, FS)[:2]): - d.text((8, 150 + i*13), l, font=FS, fill=TEXT) - if self.voice_state == 'done': - rrect(d, LW-122, LH-22, LW-4, LH-4, fill=BTNH) - centered(d, "> Send to Agent", LW-63, LH-12, FN, TEXT) - else: - d.text((8, 151), "Transcript will appear here", font=FN, fill=DIM) - def _draw_skills(self, d): + topbar(d, "Skills") r = self._get('skills') - if r and r[0] == 'ok': - self.skills = r[1] if isinstance(r[1], list) else [] - cnt = len(self.skills) - topbar(d, f"< Skills ({cnt})", dot=OK if cnt else DIM) - if not r: - centered(d, "Loading...", LW//2, LH//2, FM, WARN); return - if not self.skills: - centered(d, "No skills installed", LW//2, 70, FM, DIM) - d.text((10, 88), "picoclaw skills install ", font=FS, fill=DIM); return - - IH, Y0 = 24, 26 - for i, sk in enumerate(self.skills[self.skills_scroll:self.skills_scroll + 5]): - idx = i + self.skills_scroll - y = Y0 + i * IH - sel = (idx == self.skills_sel) - if sel: rrect(d, 4, y, LW-4, y+IH-2, fill=SEL) - # split name and status if possible - parts = sk.split() - name = parts[0] if parts else "?" - d.text((12, y+5), name[:20], font=FS, fill=TEXT if sel else DIM) - if len(parts) > 1: - d.text((140, y+5), " ".join(parts[1:])[:26], font=FN, fill=OK if sel else DIM) - - d.rectangle([0, LH-18, LW, LH], fill=PANEL) - d.text((8, LH-15), "Scroll: knob Long-press: back", font=FN, fill=DIM) + 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): - aok = self._auth_ok() - topbar(d, "< Auth", dot=OK if aok else ERR) - r = self._get('auth') - status_line = (r[1].split('\n')[0][:42] if r and r[0] == 'ok' else "Checking...") - rrect(d, 6, 25, LW-6, 70, fill=PANEL, r=4) - d.ellipse([12, 36, 22, 46], fill=OK if aok else ERR) - d.text((28, 35), status_line, font=FS, fill=OK if aok else DIM) - if self.auth_result: - for i, l in enumerate(wordwrap(self.auth_result, LW-20, d, FN)[:2]): - d.text((12, 52 + i*11), l, font=FN, fill=DIM) - LBLS = ["Login", "Logout", "Status"] - bw = (LW - 24) // 3 - for i, lbl in enumerate(LBLS): - x0 = 8 + i*(bw+4); x1 = x0 + bw - drawbtn(d, x0, 74, x1, 112, lbl, FM, sel=(i == self.auth_sel)) - if self.auth_busy: - centered(d, "Working...", LW//2, 125, FS, WARN) - d.rectangle([0, LH-18, LW, LH], fill=PANEL) - d.text((8, LH-15), "Knob: select Press: confirm Long: back", font=FN, fill=DIM) + 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", dot=DIM) - items = ['Model', 'Profile', 'Onboard', 'Gateway', 'Back'] - vals = [ - "ready" if self.rec.model_ready else "wait", - self.cfg.get('profile', 'claude'), - "run", "start", "", - ] - IH, Y0 = 24, 26 - for i, (item, val) in enumerate(zip(items, vals)): - y = Y0 + i * IH - sel = (i == self.setup_sel) - if sel: rrect(d, 4, y, LW-4, y+IH-2, fill=SEL) - d.text((20, y+5), item, font=FS, fill=TEXT if sel else DIM) - if val: - d.text((180, y+5), val, font=FN, fill=ACCENT if sel else DIM) - - if self.setup_result: - d.rectangle([0, LH-40, LW, LH-20], fill=PANEL) - d.text((8, LH-35), self.setup_result[:48], font=FN, fill=OK) - - # stats footer - d.rectangle([0, LH-18, LW, LH], fill=PANEL) - s = self.stats - d.text((8, LH-15), f"CPU:{s.get('cpu')} RAM:{s.get('mem')} T:{s.get('temp')}", font=FN, fill=DIM) + 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, "< Status", dot=DIM) - rrect(d, LW-64, 3, LW-4, 19, fill=BTN, r=3) - centered(d, "Refresh", LW-34, 11, FN, TEXT) - r = self._get('status') - if not r: - centered(d, "Loading...", LW//2, LH//2, FM, WARN); return - - lines = r[1].split('\n') if r[0] == 'ok' else [f"Error: {r[1]}"] + topbar(d, "System Status") y = 28 - for line in lines[:8]: - if y > LH - 24: break - color = ACCENT if ':' in line else TEXT - d.text((10, y), line[:46], font=FN, fill=color) - y += 14 - - d.rectangle([0, LH-18, LW, LH], fill=PANEL) - d.text((8, LH-15), "Rotate knob to scroll (TBD)", font=FN, fill=DIM) - - # ── Main loop ───────────────────────────────────────────────────────────── + for k, v in self.stats.items(): + d.text((10, y), f"{k.upper()}: {v}", font=FM, fill=TEXT) + y += 20 + def run(self): - self.running = True - print("PicoClaw app starting...") with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as rotary: while self.running: - # Rotary - rot = rotary.read_event(timeout=0) - if rot is not None: - self.on_rotate(rot) - - # Keys (knob button) - kev = keys.read_event(timeout=0) - if kev: - if kev[0] == 'key_long_press': - self.on_long_press() - elif kev[0] == 'key_release' and kev[1] == 'ENTER' and not kev[4]: - self.on_press() - - # Touch - tev = touch.read_event(timeout=0) - if tev and tev[0] == 'touch_down': - self.on_touch(tev[1], tev[2]) - - if self._dirty: - self.draw() - - time.sleep(0.03) # ~33 fps - - def cleanup(self): - if self.rec: self.rec.close() - if self.fb: self.fb.close() - - -if __name__ == '__main__': - app = App() - try: - app.run() - except KeyboardInterrupt: - pass - finally: - app.cleanup() - print("PicoClaw app stopped") + 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() From a866f939060fe3a755ffd333081f4d28297ec515 Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:11:15 -0600 Subject: [PATCH 10/16] Fix CLUCK loading and coordinate errors --- apps/CLUCK/main.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/CLUCK/main.py b/apps/CLUCK/main.py index 334537b..408ad0c 100644 --- a/apps/CLUCK/main.py +++ b/apps/CLUCK/main.py @@ -7,7 +7,7 @@ - Interactive clock that avoids pecking. - Configurable entities via gear menu. """ -import os, sys, time, random, math, threading +import os, sys, time, random, math, threading, mmap import numpy as np from PIL import Image, ImageDraw, ImageFont from input import TouchScreen, GpioKeys, RotaryEncoder @@ -17,6 +17,19 @@ 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) @@ -57,8 +70,12 @@ def draw_chicken(d, x, y, s=1.0, face_right=True, pecking=False): # Beak & Wattle bx = hx+hw if face_right else hx - d.polygon([(bx, hy+4*s), (bx, hy+8*s), (bx+(6*s if face_right else -6*s), hy+6*s)], fill=ORANGE) - d.ellipse([bx+(0 if face_right else -2*s), hy-4*s, bx+(4*s if face_right else -6*s), hy], fill=RED) # Comb + 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) # Comb + 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) # Comb # Eye ex = hx+6*s if face_right else hx+2*s @@ -196,7 +213,6 @@ def draw(self, d): class App: def __init__(self): - from framebuffer import FB self.fb = FB() self.clock_pos = [LW-60, 40] self.clock_scale = 1.0 From 7156e5f1c3eed0cd7914397b308e93a7f175f720 Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:12:09 -0600 Subject: [PATCH 11/16] Fix SCRNSVR Status tab keys and possible crash --- apps/SCRNSVR/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/SCRNSVR/main.py b/apps/SCRNSVR/main.py index b6e5975..a1166e7 100644 --- a/apps/SCRNSVR/main.py +++ b/apps/SCRNSVR/main.py @@ -150,9 +150,9 @@ def draw(self): st = get_status() lines = [ f"Daemon: {'Running' if st else 'Stopped'}", - f"Current: {st.get('current_app', 'None')}", - f"Idle: {st.get('idle_time', 0)}s", - f"Next Switch: {st.get('time_remaining', 0)}s" + 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) From 3e1c41effb12b83f942aaf8169ef62788ef25c6d Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:15:44 -0600 Subject: [PATCH 12/16] Update Tailscale app with fast FB and long-press exit --- apps/README.md | 46 ++++++++++ apps/tailscale/main.py | 201 ++++++++++++----------------------------- 2 files changed, 106 insertions(+), 141 deletions(-) create mode 100644 apps/README.md diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..9625954 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,46 @@ +# NanoKVM UserApp Suite + +This repository contains a collection of high-quality touchscreen applications for the Sipeed NanoKVM Pro. + +## Applications + +### 🐔 CLUCK (Farm Clock) +A quirky, interactive clock featuring animated farm life. +- **Clock:** High-readability display that erratically changes scale and position. Strictly clamped to prevent going off-screen. +- **In-Your-Face Chickens:** Randomly, a chicken will zoom in with big goofy eyes for a "jump scare" effect. +- **Pecking Interaction:** Chickens will peck at the clock, causing it to "flee" to another part of the screen. +- **Entity Menu:** Toggles for Chickens, Cows, Pigs, Squirrels, Farmer, Farmer's Daughter, Tractor, and House. +- **Controls:** Knob rotate to move clock; Knob press to open settings; Long-press to exit. + +### 🤖 PicoClaw (AI Agent) +A consolidated AI assistant interface with voice and chat capabilities. +- **Chat UI:** Modern chat bubble interface for both User and AI responses. +- **Voice Integration:** Built-in offline speech recognition using Vosk (no API keys required). +- **QR Login:** Generates a dynamic QR code linked to a local web server (port 8080) for easy API key configuration and provider setup. +- **System Stats:** Real-time CPU, RAM, and IP address monitoring in the footer. +- **Skills:** Management of NanoKVM skills via the picoclaw CLI. + +### 📈 EQTY (Stock Tickers) +Real-time financial monitoring for your NanoKVM screen. +- **Live Updates:** Fetches latest prices for a configured set of symbols. +- **Default Portfolio:** PSLV, CL=F (Crude Oil), CRF, LEAV, INES. +- **Visuals:** Sparkline-style price indicators and percentage change tracking. + +### 🛡️ SCRNSVR (Screensaver Manager) +A background daemon and UI to manage device idle states. +- **App Cycling:** Automatically rotates through enabled apps (EQTY, PicoClaw, CLUCK, etc.) when the device is idle. +- **Idle Timeout:** Configurable delay before the screensaver kicks in. +- **Blackout Mode:** Option to completely blank the screen to save power/life. +- **Service-Based:** Runs as a systemd service (`screensaver.service`) for persistence. + +### 🔗 Tailcode (Tailscale Status) +Quick access to network connectivity information. +- **QR Code:** Shows a join/login QR code for your Tailscale network. +- **Connectivity:** Displays current IP and node status. + +--- + +## 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/tailscale/main.py b/apps/tailscale/main.py index f401989..e2d3066 100644 --- a/apps/tailscale/main.py +++ b/apps/tailscale/main.py @@ -5,23 +5,32 @@ Screen: 172x320 physical (portrait), 320x172 logical (landscape at 270deg rotation) """ -import subprocess -import time -import io -import qrcode +import subprocess, time, io, qrcode, mmap 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 +# ── 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_BG = (10, 10, 30) COLOR_WHITE = (255, 255, 255) COLOR_CYAN = (0, 200, 255) COLOR_YELLOW = (255, 220, 50) @@ -29,166 +38,76 @@ 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' - + 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() + 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 - + except Exception: dns = ip + return ip, dns, f'https://{dns}/kvm/' 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) + 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') - img = img.resize((size, size), Image.NEAREST) - return img - + return img.resize((size, size), Image.NEAREST) def render_screen(url: str, ip: str, dns: str) -> Image.Image: - canvas = Image.new('RGB', (SCREEN_W, SCREEN_H), COLOR_BG) + canvas = Image.new('RGB', (LW, LH), BG_COLOR) 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() + 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 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 - + 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=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) - + 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 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) + fb.show(render_screen(url, ip, dns)) 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.') + 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.fill_screen((0, 0, 0)) - + fb.show(Image.new('RGB', (LW, LH), (0,0,0))) + fb.close() if __name__ == '__main__': main() From a6607284d1e26728776e2ef1d8af1c865c73afcb Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:16:15 -0600 Subject: [PATCH 13/16] Update EQTY app with fast FB and long-press exit --- apps/EQTY/main.py | 805 ++++++++-------------------------------------- 1 file changed, 137 insertions(+), 668 deletions(-) diff --git a/apps/EQTY/main.py b/apps/EQTY/main.py index 1b93def..753d9b3 100644 --- a/apps/EQTY/main.py +++ b/apps/EQTY/main.py @@ -1,687 +1,156 @@ #!/usr/bin/env python3 """ -Stock Tracker App for NanoKVM Pro -- Rotary encoder (event0, EV_REL): one detent = one letter step (debounced) -- Push button ENTER (event1): short = next char, long = save & exit editor / exit app -- Editor touch: - * Tap symbol slot -> select that slot for editing - * Tap char in slot -> select that specific char position - * Tap/drag alphabet -> select letter directly - * Tap SAVE button -> save & return to chart +EQTY v1.1.0: Real-time Stock Tracker for NanoKVM. +──────────────────────────────────────────────── +Fixes: Fast numpy FB, standard long-press exit, improved touch. """ - -import time -import json -import os -import math +import os, sys, time, json, math, threading, mmap import numpy as np from PIL import Image, ImageDraw, ImageFont - -from framebuffer import Framebuffer 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 = ['AAPL', 'MSFT', 'NVDA', 'TSLA', 'SPY'] - -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' -FONT_MONO = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf' - -COLOR_BG = (8, 12, 24) -COLOR_GRID = (30, 40, 60) -COLOR_WHITE = (230, 230, 230) -COLOR_GRAY = (120, 120, 140) -COLOR_CYAN = (0, 200, 220) -COLOR_GREEN = (60, 200, 80) -COLOR_RED = (220, 60, 60) -COLOR_YELLOW = (240, 210, 50) -COLOR_SEL = (50, 80, 160) -COLOR_ORANGE = (255, 140, 20) -COLOR_FOCUS = (60, 120, 240) -COLOR_FOCUS_BG = (18, 35, 75) -COLOR_DIM = (50, 55, 70) -COLOR_SAVE = (30, 130, 60) -COLOR_SAVE_HI = (50, 200, 90) - -ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ.' - -TIMEFRAMES = [ - ('5d', '1h', '5d/1h'), - ('1d', '5m', '1d/5m'), - ('1mo', '1d', '1mo/1d'), - ('6mo', '1wk', '6mo/wk'), - ('1y', '1wk', '1yr/wk'), -] - -FOCUS_SYMBOL = 0 -FOCUS_CHART = 1 -FOCUS_GEAR = 2 - -HEADER_H = 30 - -ZONE_SYM_X1, ZONE_SYM_X2 = 0, 90 -ZONE_CHT_X1, ZONE_CHT_X2 = 91, 284 -ZONE_GEAR_X1, ZONE_GEAR_X2 = 285, 319 - -# Editor layout constants -ED_HEADER_H = 20 # title bar height -ED_SLOT_Y1 = 20 # symbol slots top -ED_SLOT_Y2 = 62 # symbol slots bottom -ED_SLOT_H = ED_SLOT_Y2 - ED_SLOT_Y1 -ED_ALPHA_Y1 = 66 # alphabet strip top -ED_ALPHA_Y2 = 126 # alphabet strip bottom -ED_ALPHA_H = ED_ALPHA_Y2 - ED_ALPHA_Y1 -# Bottom button bar (y=148..172, full width split in half) -ED_BTN_Y1 = 148 -ED_BTN_Y2 = 172 -ED_CANCEL_X2 = 159 # CANCEL: x=0..159 -ED_SAVE_X1 = 160 # SAVE: x=160..319 - -SLOT_W = 56 -SLOT_GAP = 2 -SLOTS_TOTAL = 5 * SLOT_W + 4 * SLOT_GAP # 292 -SLOT_START_X = (SCREEN_W - SLOTS_TOTAL) // 2 # 14 - -ENCODER_DEBOUNCE = 0.20 # seconds between encoder steps (200ms handles mechanical bounce) - -# Auto-refresh intervals: (seconds, display label). 0 = off. -REFRESH_OPTIONS = [ - (0, 'Off'), - (60, '1 min'), - (300, '5 min'), - (900, '15 min'), - (1800, '30 min'), - (3600, '1 hr'), -] - - +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: - d = json.load(f) - syms = d.get('symbols', DEFAULT_SYMBOLS)[:5] - while len(syms) < 5: - syms.append('SPY') - return syms - except Exception: - return list(DEFAULT_SYMBOLS) + with open(CONFIG_FILE) as f: return json.load(f).get('symbols', DEFAULT_SYMBOLS) + except: return list(DEFAULT_SYMBOLS) - -def save_config(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': symbols}, f) - except Exception: - pass - + with open(CONFIG_FILE, 'w') as f: json.dump({'symbols': syms}, f) + except: pass -def fetch_candles(symbol, period='5d', interval='1h'): +def fetch_price(symbol): try: import yfinance as yf - df = yf.download(symbol, period=period, interval=interval, - progress=False, auto_adjust=True) - if hasattr(df.columns, 'levels'): - df.columns = df.columns.get_level_values(0) - if df is None or len(df) < 2: - return None - result = [] - for _, row in df.iterrows(): - try: - result.append((float(row['Open']), float(row['High']), - float(row['Low']), float(row['Close']))) - except Exception: - pass - return result if result else None - except Exception as e: - print(f'fetch error: {e}') - return None - - -def draw_candles(draw, candles, ax, ay, aw, ah): - if not candles: - draw.text((ax + aw // 2 - 20, ay + ah // 2), 'NO DATA', fill=COLOR_GRAY) - return - highs = [c[1] for c in candles] - lows = [c[2] for c in candles] - pmax = max(highs) * 1.001 - pmin = min(lows) * 0.999 - prng = pmax - pmin or 1 - - def py(p): - return int(ay + ah - (p - pmin) / prng * ah) - - n = len(candles) - cw = max(2, aw // n - 1) - sp = aw / n - - for i in range(1, 4): - gy = ay + int(ah * i / 4) - draw.line([(ax, gy), (ax + aw, gy)], fill=COLOR_GRID, width=1) - - for i, (o, h, l, c) in enumerate(candles): - cx = int(ax + i * sp + sp / 2) - col = COLOR_GREEN if c >= o else COLOR_RED - draw.line([(cx, py(h)), (cx, py(l))], fill=col, width=1) - bt = py(max(o, c)) - bb = max(py(min(o, c)), bt + 1) - draw.rectangle([cx - cw // 2, bt, cx + cw // 2, bb], fill=col) - - -def draw_gear(draw, cx, cy, r=10, color=COLOR_GRAY): - draw.ellipse((cx - r + 3, cy - r + 3, cx + r - 3, cy + r - 3), - outline=color, width=2) - for i in range(8): - a = (i / 8) * 2 * math.pi - x1 = cx + math.cos(a) * (r - 3) - y1 = cy + math.sin(a) * (r - 3) - x2 = cx + math.cos(a) * r - y2 = cy + math.sin(a) * r - draw.line((x1, y1, x2, y2), fill=color, width=3) - - -def render_chart(symbol, candles, sym_idx, total, focus, tf_label): - img = Image.new('RGB', (SCREEN_W, SCREEN_H), COLOR_BG) - draw = ImageDraw.Draw(img) - try: - fsm = ImageFont.truetype(FONT_PATH, 10) - fmd = ImageFont.truetype(FONT_PATH, 12) - fbg = ImageFont.truetype(FONT_BOLD, 17) - except Exception: - fsm = fmd = fbg = ImageFont.load_default() - - draw.rectangle([0, 0, SCREEN_W - 1, HEADER_H], fill=(12, 18, 40)) - - zones = [(ZONE_SYM_X1, ZONE_SYM_X2), - (ZONE_CHT_X1, ZONE_CHT_X2), - (ZONE_GEAR_X1, ZONE_GEAR_X2)] - zx1, zx2 = zones[focus] - draw.rectangle([zx1, 0, zx2, HEADER_H], fill=COLOR_FOCUS_BG) - draw.rectangle([zx1, HEADER_H - 2, zx2, HEADER_H], fill=COLOR_FOCUS) - - sym_col = COLOR_CYAN if focus == FOCUS_SYMBOL else COLOR_WHITE - draw.text((4, 7), symbol, font=fbg, fill=sym_col) - for i in range(total): - col = COLOR_CYAN if i == sym_idx else (45, 45, 70) - draw.ellipse([4 + i * 8, 2, 9 + i * 8, 7], fill=col) - - cht_col = COLOR_CYAN if focus == FOCUS_CHART else COLOR_GRAY - if candles: - lc = candles[-1][3] - fo = candles[0][0] - chg = lc - fo - pct = chg / fo * 100 if fo else 0 - pcol = COLOR_GREEN if chg >= 0 else COLOR_RED - sign = '+' if chg >= 0 else '' - draw.text((ZONE_CHT_X1 + 4, 3), f'${lc:.2f}', font=fmd, fill=COLOR_WHITE) - draw.text((ZONE_CHT_X1 + 4, 17), f'{sign}{pct:.2f}%', font=fsm, fill=pcol) - draw.text((ZONE_CHT_X1 + 80, 10), tf_label, font=fsm, fill=cht_col) - else: - draw.text((ZONE_CHT_X1 + 4, 10), 'Loading...', font=fsm, fill=COLOR_GRAY) - draw.text((ZONE_CHT_X1 + 80, 10), tf_label, font=fsm, fill=cht_col) - - gear_col = COLOR_CYAN if focus == FOCUS_GEAR else COLOR_GRAY - gcx = ZONE_GEAR_X1 + (ZONE_GEAR_X2 - ZONE_GEAR_X1) // 2 - draw_gear(draw, gcx, HEADER_H // 2, r=10, color=gear_col) - - draw_candles(draw, candles, 2, HEADER_H + 2, SCREEN_W - 4, - SCREEN_H - HEADER_H - 14) - - ts = time.strftime('%H:%M') - draw.text((SCREEN_W - 35, SCREEN_H - 12), ts, font=fsm, fill=COLOR_GRAY) - draw.text((2, SCREEN_H - 12), 'Wheel:focus Press:action Long:exit', - font=fsm, fill=COLOR_DIM) - return img - - -def render_editor(syms, editing_sym, editing_char, char_idx): - img = Image.new('RGB', (SCREEN_W, SCREEN_H), COLOR_BG) - draw = ImageDraw.Draw(img) - try: - fsm = ImageFont.truetype(FONT_PATH, 10) - fmed = ImageFont.truetype(FONT_PATH, 12) - fbg = ImageFont.truetype(FONT_BOLD, 14) - fmn = ImageFont.truetype(FONT_MONO, 13) - fal = ImageFont.truetype(FONT_BOLD, 13) # alphabet normal - fal2 = ImageFont.truetype(FONT_BOLD, 18) # alphabet selected - except Exception: - fsm = fmed = fbg = fmn = fal = fal2 = ImageFont.load_default() - - # --- Header --- - draw.rectangle([0, 0, SCREEN_W, ED_HEADER_H], fill=(14, 22, 48)) - draw.text((4, 3), 'EDIT SYMBOLS', font=fbg, fill=COLOR_CYAN) - - # --- Symbol slots --- - char_w = 10 # pixels per char in slot - for i, sym in enumerate(syms): - sx = SLOT_START_X + i * (SLOT_W + SLOT_GAP) - sel = (i == editing_sym) - bg = (22, 38, 80) if sel else (16, 24, 48) - border = COLOR_CYAN if sel else (50, 60, 90) - draw.rectangle([sx, ED_SLOT_Y1, sx + SLOT_W - 1, ED_SLOT_Y2 - 1], - fill=bg, outline=border, width=1) - sd = sym.ljust(5)[:5] - for ci, ch in enumerate(sd): - cx2 = sx + 3 + ci * char_w - cy2 = ED_SLOT_Y1 + (ED_SLOT_H - 16) // 2 - if sel and ci == editing_char: - # Highlighted current char - draw.rectangle([cx2 - 1, cy2 - 1, cx2 + char_w - 1, cy2 + 15], - fill=COLOR_ORANGE) - draw.text((cx2, cy2), ch, font=fmn, fill=COLOR_BG) - else: - draw.text((cx2, cy2), ch, font=fmn, - fill=COLOR_WHITE if sel else (80, 90, 110)) - - # --- Divider --- - draw.line([(0, ED_ALPHA_Y1 - 2), (SCREEN_W, ED_ALPHA_Y1 - 2)], - fill=(30, 40, 70), width=1) - - # --- Alphabet strip --- - n_chars = len(ALPHABET) - cell_w = SCREEN_W / n_chars # ~11.85px each - - # Background - draw.rectangle([0, ED_ALPHA_Y1, SCREEN_W, ED_ALPHA_Y2], fill=(10, 16, 36)) - - for ai, ch in enumerate(ALPHABET): - cx2 = int(ai * cell_w + cell_w / 2) - sel = (ai == char_idx) - if sel: - # Highlight background for selected letter - bx1 = int(ai * cell_w) - bx2 = int((ai + 1) * cell_w) - draw.rectangle([bx1, ED_ALPHA_Y1, bx2, ED_ALPHA_Y2], - fill=(30, 60, 120)) - # Large selected letter centered vertically - bbox = draw.textbbox((0, 0), ch, font=fal2) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - ty = ED_ALPHA_Y1 + (ED_ALPHA_H - th) // 2 - bbox[1] - draw.text((cx2 - tw // 2, ty), ch, font=fal2, fill=COLOR_YELLOW) + 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: - bbox = draw.textbbox((0, 0), ch, font=fal) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - ty = ED_ALPHA_Y1 + (ED_ALPHA_H - th) // 2 - bbox[1] - # Dim neighbours, brighter as distance shrinks - dist = min(abs(ai - char_idx), n_chars - abs(ai - char_idx)) - alpha = max(60, 180 - dist * 18) - col = (alpha, alpha, alpha) - draw.text((cx2 - tw // 2, ty), ch, font=fal, fill=col) - - # --- Instructions row --- - iy = ED_ALPHA_Y2 + 4 - draw.text((2, iy), 'Wheel / drag strip: change letter', font=fsm, fill=COLOR_GRAY) - draw.text((2, iy + 12), 'Tap slot or char to jump position', font=fsm, fill=COLOR_GRAY) - - # --- Bottom button bar --- - # CANCEL (left half) - draw.rectangle([0, ED_BTN_Y1, ED_CANCEL_X2, ED_BTN_Y2], - fill=(60, 20, 20), outline=(160, 60, 60), width=1) - bbox = draw.textbbox((0, 0), 'CANCEL', font=fmed) - tw = bbox[2] - bbox[0] - draw.text(((ED_CANCEL_X2 - tw) // 2, ED_BTN_Y1 + 5), 'CANCEL', font=fmed, fill=(220, 100, 100)) - - # SAVE (right half) - draw.rectangle([ED_SAVE_X1, ED_BTN_Y1, SCREEN_W - 1, ED_BTN_Y2], - fill=(20, 70, 30), outline=(60, 180, 80), width=1) - bbox = draw.textbbox((0, 0), 'SAVE', font=fmed) - tw = bbox[2] - bbox[0] - sx_mid = ED_SAVE_X1 + (SCREEN_W - ED_SAVE_X1) // 2 - draw.text((sx_mid - tw // 2, ED_BTN_Y1 + 5), 'SAVE', font=fmed, fill=(80, 220, 100)) - - return img - - -def render_gear_menu(selected, refresh_label): - img = Image.new('RGB', (SCREEN_W, SCREEN_H), COLOR_BG) - draw = ImageDraw.Draw(img) - try: - fsm = ImageFont.truetype(FONT_PATH, 10) - fmd = ImageFont.truetype(FONT_PATH, 14) - fbg = ImageFont.truetype(FONT_BOLD, 16) - except Exception: - fsm = fmd = fbg = ImageFont.load_default() - - draw.text((4, 4), 'SETTINGS', font=fbg, fill=COLOR_CYAN) - options = [ - 'Refresh Data Now', - f'Auto-Refresh: {refresh_label}', - 'Back', - ] - for i, label in enumerate(options): - y = 35 + i * 30 - if i == selected: - draw.rectangle([4, y - 2, SCREEN_W - 4, y + 24], - fill=COLOR_FOCUS_BG, outline=COLOR_FOCUS, width=1) - # Show arrow hint on the refresh row - suffix = ' < >' if i == 1 else '' - draw.text((12, y + 4), label + suffix, font=fmd, - fill=COLOR_CYAN if i == selected else COLOR_WHITE) - - draw.text((4, SCREEN_H - 24), 'Wheel: navigate Press: select/cycle', font=fsm, fill=COLOR_GRAY) - draw.text((4, SCREEN_H - 12), 'Long press: exit app', font=fsm, fill=COLOR_GRAY) - return img - - -def image_to_fb(img, fb): - phys = img.rotate(90, expand=True) - arr = np.array(phys, dtype=np.uint16) - rgb565 = (((arr[:,:,0] >> 3) << 11) | - ((arr[:,:,1] >> 2) << 5) | - (arr[:,:,2] >> 3)).astype('= 0 else 0 - - def do_save(): - self.symbols = [s.strip('.').strip() or 'SPY' for s in syms] - save_config(self.symbols) - self.cache.clear() - - def redraw(): - image_to_fb(render_editor(syms, editing_sym, editing_char, char_idx), self.fb) - - redraw() - - while True: - # --- Encoder: one step per detent, debounced --- - re = encoder.read_event(timeout=0) - if re: - _, delta = re - now = time.time() - if delta != 0 and (now - last_encoder_step) >= ENCODER_DEBOUNCE: - last_encoder_step = now - step = 1 if delta > 0 else -1 - set_char(char_idx + step) - redraw() - - # --- Touch --- - te = touch.read_event(timeout=0) - if te: - ev, raw_x, raw_y, _ = te - sx, sy = TouchScreen.map_coords_270(raw_x, raw_y) - - if ev == 'touch_down': - # Classify where the touch started - if ED_BTN_Y1 <= sy <= ED_BTN_Y2: - touch_ctx = 'btn' - elif ED_ALPHA_Y1 <= sy <= ED_ALPHA_Y2: - touch_ctx = 'alpha' - set_char(alpha_idx_from_x(sx)) - redraw() - else: - slot_i, char_i = slot_and_char_from_touch(sx, sy) - if slot_i is not None: - touch_ctx = 'slot' - move_to(slot_i, char_i) - redraw() - else: - touch_ctx = None - - elif ev == 'touch_move': - # Only drag-update letter if touch started on alphabet strip - if touch_ctx == 'alpha' and ED_ALPHA_Y1 <= sy <= ED_ALPHA_Y2: - new_idx = alpha_idx_from_x(sx) - if new_idx != char_idx: - set_char(new_idx) - redraw() - - elif ev == 'touch_up': - if touch_ctx == 'btn': - if sx <= ED_CANCEL_X2: # CANCEL - return # discard changes - else: # SAVE - do_save() - return - touch_ctx = None - - # --- Button --- - ke = keys.read_event(timeout=0.02) - if ke: - ev, kn, _, _, _ = ke - if ev == 'key_long_press': - do_save() - return - elif ev == 'key_press' and kn == 'ENTER': - editing_char = (editing_char + 1) % 5 - ci = ALPHABET.find(syms[editing_sym][editing_char]) - char_idx = ci if ci >= 0 else 0 - redraw() - - def run_gear_menu(self, keys, touch, encoder): - selected = 0 - opts_count = 3 - last_step = 0.0 - - def redraw(): - image_to_fb(render_gear_menu(selected, self.refresh_label()), self.fb) - - redraw() - while True: - te = touch.read_event(timeout=0) - if te and te[0] == 'touch_down': - return False - - re = encoder.read_event(timeout=0) - if re: - _, delta = re - now = time.time() - if delta != 0 and (now - last_step) >= ENCODER_DEBOUNCE: - last_step = now - step = 1 if delta > 0 else -1 - selected = (selected + step) % opts_count - redraw() - - ke = keys.read_event(timeout=0.02) - if ke: - ev, kn, _, _, _ = ke - if ev == 'key_long_press': - return True - elif ev == 'key_press' and kn == 'ENTER': - if selected == 0: # Refresh now - self.cache.clear() - return False - elif selected == 1: # Cycle refresh interval - self.refresh_idx = (self.refresh_idx + 1) % len(REFRESH_OPTIONS) - redraw() - elif selected == 2: # Back - return False + 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): - print('Stock Tracker starting...') - self.show_chart() - last_auto = time.time() - last_enc_step = 0.0 - td_time = td_x = td_y = 0 - touching = False - - with TouchScreen() as touch, GpioKeys() as keys, RotaryEncoder() as encoder: - while True: - ri = self.refresh_interval() - if ri > 0 and time.time() - last_auto > ri: - self.cache.clear() - self.show_chart() - last_auto = time.time() - - # Rotary encoder -> cycle focus (one step per detent) - re = encoder.read_event(timeout=0) - if re: - _, delta = re - now = time.time() - if delta != 0 and (now - last_enc_step) >= ENCODER_DEBOUNCE: - last_enc_step = now - step = 1 if delta > 0 else -1 - self.focus = (self.focus + step) % 3 - self.show_chart() - - # Touch - te = touch.read_event(timeout=0) - if te: - ev, x, y, _ = te - sx, sy = TouchScreen.map_coords_270(x, y) - - if ev == 'touch_down': - td_time = time.time() - td_x, td_y = sx, sy - touching = True - - elif ev == 'touch_up' and touching: - touching = False - dur = time.time() - td_time - if dur > 1.5: - break - - if td_y <= HEADER_H: - if ZONE_SYM_X1 <= td_x <= ZONE_SYM_X2: - if self.focus == FOCUS_SYMBOL: - self.run_editor(keys, touch, encoder) - else: - self.focus = FOCUS_SYMBOL - self.show_chart() - elif ZONE_CHT_X1 <= td_x <= ZONE_CHT_X2: - if self.focus == FOCUS_CHART: - self.tf_idx = (self.tf_idx + 1) % len(TIMEFRAMES) - self.cache.clear() - else: - self.focus = FOCUS_CHART - self.show_chart() - elif ZONE_GEAR_X1 <= td_x <= ZONE_GEAR_X2: - if self.focus == FOCUS_GEAR: - if self.run_gear_menu(keys, touch, encoder): - break - else: - self.focus = FOCUS_GEAR - self.show_chart() - else: - self.current = (self.current + 1) % len(self.symbols) - self.show_chart() - - # Button - ke = keys.read_event(timeout=0.02) - if ke: - ev, kn, _, _, _ = ke - if ev == 'key_long_press': - break - elif ev == 'key_press' and kn == 'ENTER': - if self.focus == FOCUS_SYMBOL: - self.run_editor(keys, touch, encoder) - self.show_chart() - elif self.focus == FOCUS_CHART: - self.tf_idx = (self.tf_idx + 1) % len(TIMEFRAMES) - self.cache.clear() - self.show_chart() - elif self.focus == FOCUS_GEAR: - if self.run_gear_menu(keys, touch, encoder): - break - self.show_chart() - - -def main(): - fb = Framebuffer('/dev/fb0', rotation=90, font_size=12) - app = StockApp(fb) - try: - app.run() - except KeyboardInterrupt: - pass - finally: - fb.fill_screen((0, 0, 0)) - - -if __name__ == '__main__': - main() + 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() From 8747d7371c7fd7411cd7e7bab4d7e5a300eb62b5 Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:17:09 -0600 Subject: [PATCH 14/16] CLUCK v1.1.0: Universal goofy zooms for all characters --- apps/CLUCK/main.py | 303 ++++++++++++++++++++++----------------------- 1 file changed, 146 insertions(+), 157 deletions(-) diff --git a/apps/CLUCK/main.py b/apps/CLUCK/main.py index 408ad0c..e2a843b 100644 --- a/apps/CLUCK/main.py +++ b/apps/CLUCK/main.py @@ -1,11 +1,8 @@ #!/usr/bin/env python3 """ -CLUCK: A quirky farm clock for NanoKVM. -Features: -- Animated chickens and farm animals. -- Erratic "in your face" moments. -- Interactive clock that avoids pecking. -- Configurable entities via gear menu. +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 @@ -51,15 +48,15 @@ def _f(sz, b=False): 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): +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 - body = [x0, y0, x0+w, y0+h] - d.ellipse(body, fill=WHITE) + d.ellipse([x0, y0, x0+w, y0+h], fill=WHITE) # Head hw = 10*s @@ -68,94 +65,121 @@ def draw_chicken(d, x, y, s=1.0, face_right=True, pecking=False): if pecking: hy += 8*s d.ellipse([hx, hy, hx+hw, hy+hw], fill=WHITE) - # Beak & Wattle + # 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) # Comb + 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) # Comb + d.ellipse([bx-4*s, hy-4*s, bx, hy], fill=RED) - # Eye - 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) + # 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=int(2*s)) - d.line([lx+4*s, y0+h, lx+4*s, y0+h+6*s], fill=ORANGE, width=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))) + 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): +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) # Body - # Spots + 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) - d.rectangle([hx, hy+8*s, hx+14*s, hy+14*s], fill=PINK) # Nose - # Legs - d.line([x0+4*s, y0+h, x0+4*s, y0+h+8*s], fill=WHITE, width=int(3*s)) - d.line([x0+w-4*s, y0+h, x0+w-4*s, y0+h+8*s], fill=WHITE, width=int(3*s)) + # 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): +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) - # Head 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) - # Snout - sx = hx+10*s if face_right else hx-2*s - d.ellipse([sx, hy+4*s, sx+6*s, hy+10*s], fill=(255,100,100)) + # 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): +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) - # Tail 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): - # Body +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) - # Head hy = y0 - 10*s d.ellipse([x0, hy, x0+w, hy+12*s], fill=SKIN) - # Hat/Hair if not is_girl: - d.rectangle([x0-2*s, hy, x0+w+2*s, hy+4*s], fill=BROWN) # Hat + 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: - d.rectangle([x0-2*s, hy, x0+w+2*s, hy+4*s], fill=YELLOW) # Hair + # 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 - # Big wheel 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=int(3*s)) - # Body + 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) - # Cabin 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) - # Roof - d.polygon([(x0-4*s, y0), (x+10*s, y0-20*s), (x0+w+4*s, y0)], fill=BROWN) - # Door - d.rectangle([x-6*s, y0+h-16*s, x+6*s, y0+h], fill=BG_COLOR) + 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: @@ -164,10 +188,10 @@ def __init__(self, kind, x, y): 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" # idle, walk, peck, zoom + self.state = "idle" self.timer = 0 self.face_right = (self.vx > 0) - self.z = y # depth sorting + self.z = y def update(self): if self.state == "zoom": @@ -175,184 +199,149 @@ def update(self): if self.timer <= 0: self.state = "idle" self.scale = 1.0 - self.x = random.randint(20, LW-20) - self.y = random.randint(40, LH-10) + self.x, self.y = random.randint(20, LW-20), random.randint(40, LH-10) return - # Movement - if random.random() < 0.02: # Change state + if random.random() < 0.02: self.state = random.choice(["idle", "walk", "walk", "peck"]) - self.vx = random.uniform(-2, 2) - self.vy = random.uniform(-0.5, 0.5) + 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 - self.x = max(10, min(LW-10, self.x)) + 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) - # Random Zoom (In your face!) - if self.kind == "chicken" and random.random() < 0.001: - self.state = "zoom" - self.scale = 5.0 - self.x, self.y = LW//2, LH-20 - self.timer = 60 # 2 seconds + # 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 # Update depth + self.z = self.y def draw(self, d): - if self.kind == "chicken": draw_chicken(d, self.x, self.y, self.scale, self.face_right, self.state=="peck") - elif self.kind == "cow": draw_cow(d, self.x, self.y, self.scale, self.face_right) - elif self.kind == "pig": draw_pig(d, self.x, self.y, self.scale, self.face_right) - elif self.kind == "squirrel": draw_squirrel(d, self.x, self.y, self.scale, self.face_right) - elif self.kind == "farmer": draw_farmer(d, self.x, self.y, self.scale, False) - elif self.kind == "daughter": draw_farmer(d, self.x, self.y, self.scale, True) + 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-60, 40] + 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 - # Config self.opts = { - "chickens": True, - "cows": False, - "pigs": False, - "squirrels": True, - "farmer": False, - "daughter": False, - "tractor": False, - "house": False + "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()) + self.opt_keys = list(self.opts.keys()) + ["SAVE & EXIT"] self._spawn_entities() def _spawn_entities(self): self.entities = [] - if self.opts["house"]: self.entities.append(Entity("house", 40, 60)) - if self.opts["tractor"]: self.entities.append(Entity("tractor", LW-40, 80)) - - count = 3 if self.opts["chickens"] else 0 - for _ in range(count): self.entities.append(Entity("chicken", random.randint(20, LW), random.randint(40, LH))) - - if self.opts["cows"]: self.entities.append(Entity("cow", random.randint(20, LW), random.randint(40, LH))) - if self.opts["pigs"]: self.entities.append(Entity("pig", random.randint(20, LW), random.randint(40, LH))) - if self.opts["squirrels"]: self.entities.append(Entity("squirrel", random.randint(20, LW), random.randint(40, LH))) - if self.opts["farmer"]: self.entities.append(Entity("farmer", random.randint(20, LW), random.randint(40, LH))) - if self.opts["daughter"]: self.entities.append(Entity("daughter", random.randint(20, LW), random.randint(40, LH))) + 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): - # Random big clock moment - 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 - - # Move clock away from chickens if pecking + 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 = self.clock_pos[0] - e.x - dy = self.clock_pos[1] - e.y - dist = math.hypot(dx, dy) - if dist < 40: - self.clock_pos[0] += dx * 0.1 - self.clock_pos[1] += dy * 0.1 - - # Clamp clock - self.clock_pos[0] = max(40, min(LW-40, self.clock_pos[0])) - self.clock_pos[1] = max(20, min(LH-20, self.clock_pos[1])) + 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 - # Smooth scale 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) - # Draw entities sorted by Y (depth) self.entities.sort(key=lambda e: e.z) for e in self.entities: - e.update() + if not self.menu_open: e.update() e.draw(d) - # Draw Clock + # 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 = self.clock_pos + cx, cy = (LW//2, LH//2) if self.clock_scale > 1.5 else self.clock_pos - # Center clock if big - if self.clock_scale > 1.5: - cx, cy = LW//2, LH//2 - - d.text((cx - cw//2, cy - ch//2), t_str, font=font, fill=WHITE) + # 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] - # Gear Icon (Top Right) - d.text((LW-20, 4), "@", font=F_MENU, fill=GRAY) + 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 - # Menu Overlay if self.menu_open: - d.rectangle([20, 20, LW-20, LH-20], fill=(20, 20, 30), outline=WHITE) - d.text((100, 24), "OPTIONS", font=F_MENU, fill=WHITE) - y = 44 - for i, k in enumerate(self.opt_keys): - if y > LH-30: break - sel = (i == self.menu_sel) - val = self.opts[k] - label = f"{'>' if sel else ' '} {k.upper()}: {'ON' if val else 'OFF'}" - d.text((40, y), label, font=F_MENU, fill=ORANGE if sel else WHITE) - y += 16 - + 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: - # Input r = rotary.read_event(0) if r: if self.menu_open: self.menu_sel = (self.menu_sel + r) % len(self.opt_keys) - else: - # Rotate moves clock for fun - self.clock_pos[0] += r * 5 - - k = keys.read_event(0) - if k and k[0] == 'key_release' and k[1] == 'ENTER': - if self.menu_open: - key = self.opt_keys[self.menu_sel] - self.opts[key] = not self.opts[key] - self._spawn_entities() - else: - self.menu_open = True + 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]) - # Toggle menu - if tx > LW-30 and ty < 30: - self.menu_open = not self.menu_open - elif self.menu_open and (tx < 20 or tx > LW-20 or ty < 20 or ty > LH-20): - self.menu_open = False - elif not self.menu_open: - # Move clock to touch - self.clock_pos = [tx, ty] + 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] - if not self.menu_open: - self.update_clock() - self.draw() - time.sleep(0.08) + time.sleep(0.05) + self.fb.close() if __name__ == "__main__": App().run() From e2cd3970dd104561fc40bc8fbbb66c98fd3c2f17 Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:25:14 -0600 Subject: [PATCH 15/16] Update README with goofy zoom details and performance improvements --- apps/README.md | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/README.md b/apps/README.md index 9625954..6b7e524 100644 --- a/apps/README.md +++ b/apps/README.md @@ -7,36 +7,41 @@ This repository contains a collection of high-quality touchscreen applications f ### 🐔 CLUCK (Farm Clock) A quirky, interactive clock featuring animated farm life. - **Clock:** High-readability display that erratically changes scale and position. Strictly clamped to prevent going off-screen. -- **In-Your-Face Chickens:** Randomly, a chicken will zoom in with big goofy eyes for a "jump scare" effect. +- **Goofy Zooms:** Every character has a unique "in your face" jump-scare animation: + - **Chicken:** Big goofy eyes. + - **Cow:** Massive snout and long eyelashes. + - **Pig:** Huge snout and spiral dizzy eyes. + - **Squirrel:** Giant cheeks and buck teeth. + - **Farmer:** Massive bushy beard and shocked stare. + - **Farmer's Daughter:** Yellow pigtails and surprised mouth. - **Pecking Interaction:** Chickens will peck at the clock, causing it to "flee" to another part of the screen. -- **Entity Menu:** Toggles for Chickens, Cows, Pigs, Squirrels, Farmer, Farmer's Daughter, Tractor, and House. +- **Entity Menu:** Toggles for all characters, Tractor, and House. - **Controls:** Knob rotate to move clock; Knob press to open settings; Long-press to exit. ### 🤖 PicoClaw (AI Agent) A consolidated AI assistant interface with voice and chat capabilities. - **Chat UI:** Modern chat bubble interface for both User and AI responses. - **Voice Integration:** Built-in offline speech recognition using Vosk (no API keys required). -- **QR Login:** Generates a dynamic QR code linked to a local web server (port 8080) for easy API key configuration and provider setup. -- **System Stats:** Real-time CPU, RAM, and IP address monitoring in the footer. +- **QR Login:** Generates a dynamic QR code linked to a local web server (port 8080) for easy API key configuration. +- **System Stats:** Real-time CPU, RAM, and IP address monitoring. - **Skills:** Management of NanoKVM skills via the picoclaw CLI. ### 📈 EQTY (Stock Tickers) -Real-time financial monitoring for your NanoKVM screen. -- **Live Updates:** Fetches latest prices for a configured set of symbols. -- **Default Portfolio:** PSLV, CL=F (Crude Oil), CRF, LEAV, INES. -- **Visuals:** Sparkline-style price indicators and percentage change tracking. +Real-time financial monitoring with high-speed rendering. +- **Live Updates:** Fetches latest prices for symbols like PSLV, CL=F, CRF, LEAV, INES. +- **Visuals:** High-performance sparkline-style price indicators. +- **Controls:** Knob rotate to switch symbols; Long-press to exit. ### 🛡️ SCRNSVR (Screensaver Manager) A background daemon and UI to manage device idle states. -- **App Cycling:** Automatically rotates through enabled apps (EQTY, PicoClaw, CLUCK, etc.) when the device is idle. -- **Idle Timeout:** Configurable delay before the screensaver kicks in. -- **Blackout Mode:** Option to completely blank the screen to save power/life. -- **Service-Based:** Runs as a systemd service (`screensaver.service`) for persistence. +- **App Cycling:** Automatically rotates through enabled apps when the device is idle. +- **Status Dashboard:** Live view of idle time, current app, and next switch countdown. +- **Service-Based:** Runs as a systemd service for persistence. ### 🔗 Tailcode (Tailscale Status) Quick access to network connectivity information. - **QR Code:** Shows a join/login QR code for your Tailscale network. -- **Connectivity:** Displays current IP and node status. +- **Performance:** Optimized with fast numpy-based rendering. --- From c0b12db6ca0299fe56ff839404e6c1debb679a1d Mon Sep 17 00:00:00 2001 From: Sideflare Date: Sat, 7 Mar 2026 17:25:52 -0600 Subject: [PATCH 16/16] Final README update: Add clear installation and dependency instructions for GitHub users --- apps/README.md | 54 ++++++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/apps/README.md b/apps/README.md index 6b7e524..dcc77ef 100644 --- a/apps/README.md +++ b/apps/README.md @@ -2,50 +2,52 @@ This repository contains a collection of high-quality touchscreen applications for the Sipeed NanoKVM Pro. -## Applications +## 🚀 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. -- **Clock:** High-readability display that erratically changes scale and position. Strictly clamped to prevent going off-screen. -- **Goofy Zooms:** Every character has a unique "in your face" jump-scare animation: - - **Chicken:** Big goofy eyes. - - **Cow:** Massive snout and long eyelashes. - - **Pig:** Huge snout and spiral dizzy eyes. - - **Squirrel:** Giant cheeks and buck teeth. - - **Farmer:** Massive bushy beard and shocked stare. - - **Farmer's Daughter:** Yellow pigtails and surprised mouth. -- **Pecking Interaction:** Chickens will peck at the clock, causing it to "flee" to another part of the screen. -- **Entity Menu:** Toggles for all characters, Tractor, and House. -- **Controls:** Knob rotate to move clock; Knob press to open settings; Long-press to exit. +- **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. -- **Chat UI:** Modern chat bubble interface for both User and AI responses. -- **Voice Integration:** Built-in offline speech recognition using Vosk (no API keys required). -- **QR Login:** Generates a dynamic QR code linked to a local web server (port 8080) for easy API key configuration. -- **System Stats:** Real-time CPU, RAM, and IP address monitoring. -- **Skills:** Management of NanoKVM skills via the picoclaw CLI. +- **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. -- **Live Updates:** Fetches latest prices for symbols like PSLV, CL=F, CRF, LEAV, INES. -- **Visuals:** High-performance sparkline-style price indicators. -- **Controls:** Knob rotate to switch symbols; Long-press to exit. +- **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. -- **App Cycling:** Automatically rotates through enabled apps when the device is idle. -- **Status Dashboard:** Live view of idle time, current app, and next switch countdown. -- **Service-Based:** Runs as a systemd service for persistence. +- **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. -- **QR Code:** Shows a join/login QR code for your Tailscale network. -- **Performance:** Optimized with fast numpy-based rendering. +- **Features:** Optimized numpy rendering. Displays your Tailscale IP and a login QR code. --- -## Global Controls +## 🎮 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.