#!/usr/bin/env python3 """ JARVIS Display ────────────── Renders the JARVIS UI overlay via X11. Development: python display.py Kiosk: DISPLAY=:0 python display.py --fullscreen Weather via Open-Meteo (free, no API key). Location set by --location postcode (default BH8 8JZ). """ import os import json import math import threading import time as _time import argparse import urllib.request import urllib.parse from datetime import datetime _parser = argparse.ArgumentParser(description='JARVIS display', add_help=True) _parser.add_argument('--fullscreen', '-fs', action='store_true', help='Run fullscreen under X11/Wayland') _parser.add_argument('--width', type=int, default=1280, help='Screen width (default 1280)') _parser.add_argument('--height', type=int, default=800, help='Screen height (default 800)') _parser.add_argument('--location', type=str, default='BH8 8JZ', help='UK postcode for weather (default BH8 8JZ)') _args, _ = _parser.parse_known_args() import pygame # ── Colour palette ─────────────────────────────────────────────────────────── BLACK = ( 0, 0, 0) WHITE = (255, 255, 255) GRAY = (160, 160, 160) DIM_GRAY = ( 80, 80, 80) WIRE_COL = ( 58, 58, 58) WIRE_EYE = ( 95, 95, 95) # ── WMO weather codes (Open-Meteo) ──────────────────────────────────────────── _WMO = { 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', 45: 'Fog', 48: 'Freezing fog', 51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle', 61: 'Light rain', 63: 'Rain', 65: 'Heavy rain', 71: 'Light snow', 73: 'Snow', 75: 'Heavy snow', 77: 'Snow grains', 80: 'Light showers', 81: 'Showers', 82: 'Heavy showers', 85: 'Snow showers', 86: 'Heavy snow showers', 95: 'Thunderstorm', 96: 'Thunderstorm + hail', 99: 'Thunderstorm + hail', } # ── Weather state (updated by background thread) ────────────────────────────── _weather: dict = {} # keys: 'week' (list), 'hours' (list), 'location' (str) _weather_lock = threading.Lock() def _postcode_to_latlon(postcode: str) -> tuple[float, float]: url = 'https://api.postcodes.io/postcodes/' + urllib.parse.quote(postcode.replace(' ', '')) with urllib.request.urlopen(url, timeout=10) as r: data = json.loads(r.read()) return data['result']['latitude'], data['result']['longitude'] def _fetch_weather(lat: float, lon: float) -> None: url = ( 'https://api.open-meteo.com/v1/forecast' f'?latitude={lat:.4f}&longitude={lon:.4f}' '&daily=weather_code,temperature_2m_max,temperature_2m_min' '&hourly=temperature_2m,weather_code' '&timezone=Europe%2FLondon' '&forecast_days=7' ) with urllib.request.urlopen(url, timeout=10) as r: data = json.loads(r.read()) daily = data['daily'] hourly = data['hourly'] week = [] for i, date_str in enumerate(daily['time']): d = datetime.strptime(date_str, '%Y-%m-%d') week.append({ 'day': 'Today' if i == 0 else d.strftime('%A'), 'date': d.strftime('%-d %b'), 'high': round(daily['temperature_2m_max'][i]), 'low': round(daily['temperature_2m_min'][i]), 'desc': _WMO.get(daily['weather_code'][i], ''), }) now = datetime.now() hours = [] for i, ts in enumerate(hourly['time']): t = datetime.strptime(ts, '%Y-%m-%dT%H:%M') if t < now or t.date() != now.date(): continue hours.append({ 'label': t.strftime('%-I%p').lower(), 'temp': round(hourly['temperature_2m'][i]), 'desc': _WMO.get(hourly['weather_code'][i], ''), }) with _weather_lock: _weather['week'] = week _weather['hours'] = hours def _weather_worker(lat: float, lon: float) -> None: while True: try: _fetch_weather(lat, lon) except Exception: pass _time.sleep(1800) # ── Wireframe face ──────────────────────────────────────────────────────────── def _hw(t: float, rx: float) -> float: if t <= 0.0: return rx * math.sqrt(max(0.0, 1.0 - t * t)) 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: N_H, N_V, PTS = 13, 11, 40 def sc(xf, t): w = _hw(t, rx) 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, col=WIRE_COL): if len(pts) >= 2: pygame.draw.lines(surface, col, False, pts, 1) t_vals = [-1.0 + 2.0*i/(PTS-1) for i in range(PTS)] draw([sc(-1.0, t) for t in t_vals]) draw([sc( 1.0, t) for t in t_vals]) pygame.draw.lines(surface, WIRE_COL, False, [sc(-1.0,-1.0), sc(1.0,-1.0)], 1) pygame.draw.lines(surface, WIRE_COL, False, [sc(-0.6,1.0), sc(0.0,1.02), sc(0.6,1.0)], 1) 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)]) 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_t, eye_xf = -0.38, 0.35 eye_w = _hw(eye_t, rx) 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) 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(29)] pygame.draw.lines(surface, WIRE_EYE, True, pts, 1) 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)]) draw([(cx+inner, e_btm), (cx+nose_w, nose_y)]) # ── Clock ───────────────────────────────────────────────────────────────────── def draw_clock(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None: now = datetime.now() t_surf = fonts['large'].render(now.strftime('%-H:%M'), True, WHITE) s_surf = fonts['small'].render(now.strftime('%S'), True, DIM_GRAY) surface.blit(fonts['small'].render(now.strftime('%A, %-d %B %Y'), True, GRAY), (x, y)) 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)) # ── Weather panel ───────────────────────────────────────────────────────────── _WEATHER_COLS = 5 _WEATHER_COL_W = 66 # px per column — 5 cols = 330px total def draw_weather(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None: with _weather_lock: week = list(_weather.get('week', [])) hours = list(_weather.get('hours', [])) th = fonts['tiny'].get_height() row = th + 2 def _trunc(s, n=11): return s if len(s) <= n else s[:n-1] + '…' def _col(items, start_y, label_fn, row2_fn, row3_fn): for i, item in enumerate(items[:_WEATHER_COLS]): cx = x + i * _WEATHER_COL_W surface.blit(fonts['tiny'].render(label_fn(item), True, GRAY), (cx, start_y)) surface.blit(fonts['tiny'].render(row2_fn(item), True, WHITE), (cx, start_y + row)) surface.blit(fonts['tiny'].render(row3_fn(item), True, DIM_GRAY), (cx, start_y + 2*row)) if not week: surface.blit(fonts['tiny'].render('Weather loading…', True, DIM_GRAY), (x, y)) return # 5-day section _col(week, y, lambda d: 'Today' if d['day'] == 'Today' else d['day'][:3], lambda d: f"{d['high']}°/{d['low']}°", lambda d: _trunc(d['desc'])) # 5-hour section if hours: _col(hours[:_WEATHER_COLS], y + 3*row + 8, lambda h: h['label'], lambda h: f"{h['temp']}°", lambda h: _trunc(h['desc'])) 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: 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 _args.fullscreen: 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(38), 'small': _load_font(13), 'tiny': _load_font(11), } face_rx = int(min(W, H) * 0.085) face_ry = int(min(W, H) * 0.100) face_cx = W // 2 face_cy = int(face_ry * 1.2) + 8 dot_y = int(H * 0.895) clock_block_h = fonts['small'].get_height() + 4 + fonts['large'].get_height() clock_y = face_cy - clock_block_h // 2 weather_x = W - _WEATHER_COLS * _WEATHER_COL_W - 15 # Resolve postcode and start weather thread try: lat, lon = _postcode_to_latlon(_args.location) threading.Thread(target=_weather_worker, args=(lat, lon), daemon=True).start() except Exception: pass _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() iw, ih = raw.get_size() scale = min((face_rx * 2.2) / iw, (face_ry * 2.4) / ih) face_img = pygame.transform.smoothscale( raw, (max(1, int(iw*scale)), max(1, int(ih*scale)))) pg_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 and event.key in (pygame.K_ESCAPE, pygame.K_q): 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) draw_clock(screen, fonts, 20, clock_y) draw_weather(screen, fonts, weather_x, clock_y) draw_dot(screen, W // 2, dot_y) pygame.display.flip() pg_clock.tick(10) if __name__ == '__main__': main()