#!/usr/bin/env python3 """ JARVIS Display ────────────── Renders the JARVIS UI overlay. Works in two modes: X11 (development laptop): python display.py Linux framebuffer (device – no windowing system): python display.py --framebuffer # or JARVIS_FB=1 python display.py Framebuffer prerequisites on Debian (device): sudo apt install python3-pygame sudo usermod -aG video $USER # then log out/in # If fbdev fails (common on newer kernels) try KMS/DRM: SDL_VIDEODRIVER=kmsdrm python display.py --framebuffer """ import os import math import argparse from datetime import datetime # ── CLI / env-var flags ───────────────────────────────────────────────────── # These must be parsed BEFORE pygame is imported so we can set SDL env vars # in time for pygame.init() to pick them up. _parser = argparse.ArgumentParser(description='JARVIS display', add_help=True) _parser.add_argument( '--framebuffer', '-fb', action='store_true', help='Render to Linux framebuffer instead of an X11/Wayland window') _parser.add_argument('--width', type=int, default=1280, help='Screen / framebuffer width (default 1280)') _parser.add_argument('--height', type=int, default=800, help='Screen / framebuffer height (default 800)') _args, _ = _parser.parse_known_args() USE_FB: bool = _args.framebuffer or bool(os.environ.get('JARVIS_FB')) if USE_FB: # SDL2 env vars must be set before pygame.display.init() os.environ.setdefault('SDL_VIDEODRIVER', 'kmsdrm') os.environ.setdefault('SDL_RENDER_DRIVER', 'software') os.environ.setdefault('SDL_FBDEV', os.environ.get('JARVIS_FBDEV', '/dev/fb0')) # Prevent SDL trying to open a mouse device on the framebuffer os.environ.setdefault('SDL_NOMOUSE', '1') import pygame # noqa: E402 — import after env setup # ── Colour palette ─────────────────────────────────────────────────────────── BLACK = ( 0, 0, 0) WHITE = (255, 255, 255) GRAY = (160, 160, 160) DIM_GRAY = ( 80, 80, 80) WIRE_COL = ( 58, 58, 58) # subdued wireframe line colour # ── Wireframe face ──────────────────────────────────────────────────────────── # Pure 2-D face drawing. Face shape is defined by an explicit width profile; # eye sockets, nose bridge and chin are drawn as distinct features so the # brain immediately reads "face" rather than a geometric solid. WIRE_EYE = ( 95, 95, 95) # slightly brighter for eye/feature lines def _hw(t: float, rx: float) -> float: """ Face half-width in pixels at normalised height t. t = -1 → crown (top) t = 0 → cheek level (widest, ~= rx) t = +1 → chin tip Upper half: rounded ellipse. Lower half: faster taper toward chin. """ if t <= 0.0: return rx * math.sqrt(max(0.0, 1.0 - t * t)) else: return rx * math.sqrt(max(0.0, 1.0 - t * t)) * (1.0 - 0.30 * t) def draw_wireframe_face(surface: pygame.Surface, cx: int, cy: int, rx: float, ry: float) -> None: """ Draw a 2-D face wireframe centred at (cx, cy). Coordinate system: t ∈ [−1, +1] (−1=crown, 0=cheeks, +1=chin) x_fraction ∈ [−1, +1] (fraction of half-width at t) """ N_H = 13 # horizontal grid lines N_V = 11 # vertical grid lines (including edges) PTS = 40 # interpolation steps per curve # Helpers ───────────────────────────────────────────────────────────────── def sc(xf: float, t: float) -> tuple[int, int]: """xf (fraction of half-width) + height t → screen pixel.""" w = _hw(t, rx) # Slight forward bow: face curves toward viewer at cheek level bow = int(0.06 * w * (1.0 - t * t) * (1.0 - xf * xf)) return (int(cx + xf * w), int(cy + t * ry) - bow) def draw(pts, close=False, col=WIRE_COL): if len(pts) >= 2: pygame.draw.lines(surface, col, close, pts, 1) # Face outline (left and right silhouette) ──────────────────────────────── t_vals = [(-1.0 + 2.0 * i / (PTS - 1)) for i in range(PTS)] draw([sc(-1.0, t) for t in t_vals]) # left edge draw([sc( 1.0, t) for t in t_vals]) # right edge # Crown arc (straight line across the very top) draw([sc(-1.0, -1.0), sc(1.0, -1.0)]) # Chin arc draw([sc(-0.6, 1.0), sc(0.0, 1.02), sc(0.6, 1.0)]) # Horizontal grid lines ─────────────────────────────────────────────────── for i in range(1, N_H): t = -1.0 + 2.0 * i / N_H draw([sc(-1.0 + 2.0 * j / (PTS - 1), t) for j in range(PTS)]) # Vertical grid lines ───────────────────────────────────────────────────── for i in range(1, N_V): xf = -1.0 + 2.0 * i / N_V draw([sc(xf, t) for t in t_vals]) # ── Eye sockets ─────────────────────────────────────────────────────────── # Each eye is a small ellipse drawn in a slightly brighter colour so it # immediately reads as a facial feature. eye_t = -0.38 # height: upper face (~35% from crown) eye_xf = 0.35 # lateral offset (fraction of half-width at eye_t) eye_w = _hw(eye_t, rx) # face half-width at eye level e_rx = int(eye_w * 0.24) e_ry = int(ry * 0.09) for sign in (-1, +1): ecx = int(cx + sign * eye_xf * eye_w) ecy = int(cy + eye_t * ry) eye_pts = [ (int(ecx + e_rx * math.cos(2 * math.pi * k / 28)), int(ecy + e_ry * math.sin(2 * math.pi * k / 28))) for k in range(28) ] draw(eye_pts + [eye_pts[0]], col=WIRE_EYE) # ── Nose bridge ─────────────────────────────────────────────────────────── # Two short lines running from inner eye corners down to nose tip inner = int(eye_w * 0.12) e_btm = int(cy + eye_t * ry) + e_ry nose_y = int(cy + 0.08 * ry) nose_w = int(eye_w * 0.07) draw([(cx - inner, e_btm), (cx - nose_w, nose_y)], col=WIRE_COL) draw([(cx + inner, e_btm), (cx + nose_w, nose_y)], col=WIRE_COL) # ── Clock / date ───────────────────────────────────────────────────────────── def draw_clock(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None: now = datetime.now() date_str = now.strftime('%A, %B %-d, %Y') time_str = now.strftime('%-H:%M') secs_str = now.strftime('%S') # Date row surface.blit(fonts['small'].render(date_str, True, GRAY), (x, y)) # Large time + superscript seconds t_surf = fonts['large'].render(time_str, True, WHITE) s_surf = fonts['small'].render(secs_str, True, DIM_GRAY) ty = y + fonts['small'].get_height() + 4 surface.blit(t_surf, (x, ty)) surface.blit(s_surf, (x + t_surf.get_width() + 2, ty + t_surf.get_height() - s_surf.get_height() - 8)) # ── Status line ─────────────────────────────────────────────────────────────── def draw_status(surface: pygame.Surface, fonts: dict, text: str, cx: int, y: int) -> None: surf = fonts['small'].render(text, True, GRAY) surface.blit(surf, (cx - surf.get_width() // 2, y)) def draw_dot(surface: pygame.Surface, cx: int, y: int, radius: int = 9) -> None: pygame.draw.circle(surface, WHITE, (cx, y), radius) # ── Font helper ─────────────────────────────────────────────────────────────── def _load_font(size: int, bold: bool = False) -> pygame.font.Font: """Try a list of clean system fonts, fall back to pygame built-in.""" for name in ('DejaVuSans', 'FreeSans', 'LiberationSans', 'Helvetica', 'Arial', None): try: f = pygame.font.SysFont(name, size, bold=bold) if f: return f except Exception: pass return pygame.font.Font(None, size) # ── Main loop ───────────────────────────────────────────────────────────────── def main() -> None: pygame.init() W, H = _args.width, _args.height if USE_FB: screen = pygame.display.set_mode((W, H), pygame.FULLSCREEN | pygame.NOFRAME) else: screen = pygame.display.set_mode((W, H)) pygame.display.set_caption('JARVIS') pygame.mouse.set_visible(False) fonts = { 'large': _load_font(56), 'small': _load_font(18), } # Layout anchors — all relative to screen size so they adapt to resolution face_rx = int(min(W, H) * 0.115) # half-width at cheek level face_ry = int(min(W, H) * 0.135) # half-height crown→chin face_cx = W // 2 face_cy = int(face_ry * 1.2) + 8 # nearly touches the top edge status_y = int(H * 0.82) dot_y = int(H * 0.895) # ── Load face wireframe PNG if present ──────────────────────────────────── # Place any face wireframe PNG at assets/face_wire.png and it will be # used automatically. Black background is composited away via BLEND_ADD # so only the bright lines show. Falls back to parametric drawing. _here = os.path.dirname(os.path.abspath(__file__)) _png_path = os.path.join(_here, 'assets', 'face_wire.png') face_img = None if os.path.exists(_png_path): raw = pygame.image.load(_png_path).convert() img_src_w, img_src_h = raw.get_size() # Scale to fit face slot while preserving aspect ratio scale = min((face_rx * 2.2) / img_src_w, (face_ry * 2.4) / img_src_h) img_w = max(1, int(img_src_w * scale)) img_h = max(1, int(img_src_h * scale)) face_img = pygame.transform.smoothscale(raw, (img_w, img_h)) status_text = 'Initializing...' clock = pygame.time.Clock() while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() return if event.type == pygame.KEYDOWN: if event.key in (pygame.K_ESCAPE, pygame.K_q): pygame.quit() return # Touch / mouse tap anywhere also quits in kiosk mode if USE_FB and event.type == pygame.MOUSEBUTTONDOWN: pygame.quit() return screen.fill(BLACK) if face_img: screen.blit(face_img, face_img.get_rect(center=(face_cx, face_cy)), special_flags=pygame.BLEND_ADD) else: draw_wireframe_face(screen, face_cx, face_cy, face_rx, face_ry) clock_block_h = fonts['small'].get_height() + 4 + fonts['large'].get_height() draw_clock(screen, fonts, 20, face_cy - clock_block_h // 2) draw_status(screen, fonts, status_text, W // 2, status_y) draw_dot(screen, W // 2, dot_y) pygame.display.flip() clock.tick(10) # 10 fps is plenty for a status screen if __name__ == '__main__': main()