#!/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). News via BBC RSS (no API key). Stocks via Yahoo Finance public quote API (no API key). """ import os import json import math import threading import time as _time import argparse import urllib.request import urllib.parse import xml.etree.ElementTree as ET 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)') _args, _ = _parser.parse_known_args() import pygame # ── Locations to cycle through (weather) ───────────────────────────────────── _LOCATIONS = ['Poole', 'Portsmouth', 'Besancon', 'Paris', 'Gorinchem'] # ── Stock indices ───────────────────────────────────────────────────────────── _INDICES = [ ('^FTSE', 'FTSE 100'), ('^FCHI', 'CAC 40'), ('^GDAXI', 'DAX'), ('^STOXX50E', 'Euro Stoxx 50'), ('^GSPC', 'S&P 500'), ('^DJI', 'DJIA'), ('^IXIC', 'Nasdaq'), ('^N225', 'Nikkei 225'), ('^HSI', 'Hang Seng'), ('000001.SS', 'Shanghai'), ('^BSESN', 'Sensex'), ('^NSEI', 'Nifty 50'), ('^AXJO', 'ASX 200'), ('^VIX', 'VIX'), ] # ── 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) GREEN = ( 80, 200, 80) RED = (200, 80, 80) # ── WMO weather codes ───────────────────────────────────────────────────────── _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', } # ── Shared HTTP helper ──────────────────────────────────────────────────────── _HEADERS = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) JARVIS/1.0'} def _get(url, timeout=15): req = urllib.request.Request(url, headers=_HEADERS) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read() # ═══════════════════════════════════════════════════════════════════════════════ # NEWS # ═══════════════════════════════════════════════════════════════════════════════ _headlines: list[str] = [] _headlines_lock = threading.Lock() def _fetch_news() -> None: data = _get('https://feeds.bbci.co.uk/news/rss.xml') root = ET.fromstring(data) items = root.findall('.//item') titles = [it.findtext('title', '').strip() for it in items] titles = [t for t in titles if t][:20] with _headlines_lock: _headlines.clear() _headlines.extend(titles) def _news_worker() -> None: while True: try: _fetch_news() except Exception: pass _time.sleep(300) # refresh every 5 minutes # ═══════════════════════════════════════════════════════════════════════════════ # STOCKS # ═══════════════════════════════════════════════════════════════════════════════ # Each entry: {'name': str, 'price': str, 'pct': float, 'pct_str': str} _stocks: list[dict] = [] _stocks_lock = threading.Lock() def _fetch_one_stock(symbol: str) -> dict | None: url = (f'https://query2.finance.yahoo.com/v8/finance/chart/{urllib.parse.quote(symbol)}' f'?interval=1d&range=2d') data = json.loads(_get(url)) result = data.get('chart', {}).get('result') if not result: return None meta = result[0]['meta'] price = meta.get('regularMarketPrice', 0) prev = meta.get('chartPreviousClose') or meta.get('previousClose', price) pct = ((price - prev) / prev * 100) if prev else 0.0 sign = '+' if pct >= 0 else '' return { 'price': f'{price:,.2f}', 'pct': pct, 'pct_str': f'{sign}{pct:.2f}%', } def _fetch_stocks() -> None: fresh = [] for sym, name in _INDICES: try: q = _fetch_one_stock(sym) except Exception: q = None if q: q['name'] = name fresh.append(q) if fresh: with _stocks_lock: _stocks.clear() _stocks.extend(fresh) def _stocks_worker() -> None: while True: try: _fetch_stocks() except Exception: pass _time.sleep(300) # refresh every 5 minutes # ═══════════════════════════════════════════════════════════════════════════════ # WEATHER # ═══════════════════════════════════════════════════════════════════════════════ _weather_all: list[dict] = [] _weather_lock = threading.Lock() def _geocode(city: str) -> tuple[float, float, str]: url = ('https://geocoding-api.open-meteo.com/v1/search' f'?name={urllib.parse.quote(city)}&count=1&language=en&format=json') data = json.loads(_get(url)) r = data['results'][0] return r['latitude'], r['longitude'], r['name'] def _fetch_one(lat: float, lon: float) -> dict: 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=auto&forecast_days=7' ) data = json.loads(_get(url)) 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'), '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']): if len(hours) >= 5: break t = datetime.strptime(ts, '%Y-%m-%dT%H:%M') if t <= now: continue hours.append({ 'label': t.strftime('%-I%p').lower(), 'temp': round(hourly['temperature_2m'][i]), 'desc': _WMO.get(hourly['weather_code'][i], ''), }) return {'week': week, 'hours': hours} def _weather_worker(locations: list[tuple[float, float, str]]) -> None: while True: fresh = [] for lat, lon, name in locations: try: entry = _fetch_one(lat, lon) except Exception: entry = {'week': [], 'hours': []} entry['town'] = name fresh.append(entry) with _weather_lock: _weather_all.clear() _weather_all.extend(fresh) _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, cx, cy, rx, ry): 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, fonts, x, y): 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)) # ═══════════════════════════════════════════════════════════════════════════════ # TICKER (shared scroll renderer) # ═══════════════════════════════════════════════════════════════════════════════ # Cache: key → (source_text, Surface) _ticker_cache: dict[str, tuple[str, pygame.Surface]] = {} def _build_news_surf(font) -> pygame.Surface | None: with _headlines_lock: items = list(_headlines) if not items: return None text = ' ◆ '.join(items) key = 'news' if key in _ticker_cache and _ticker_cache[key][0] == text: return _ticker_cache[key][1] surf = font.render(text, True, GRAY) _ticker_cache[key] = (text, surf) return surf def _build_stocks_surf(font) -> pygame.Surface | None: with _stocks_lock: items = list(_stocks) if not items: return font.render('Market data loading…', True, DIM_GRAY) sep = font.render(' | ', True, DIM_GRAY) # Build a key from current data to detect changes key = 'stocks' data_key = '|'.join(f"{s['name']}{s['price']}{s['pct_str']}" for s in items) if key in _ticker_cache and _ticker_cache[key][0] == data_key: return _ticker_cache[key][1] # Render each segment: name in GRAY, price+pct in green/red parts: list[pygame.Surface] = [] for i, s in enumerate(items): col = GREEN if s['pct'] > 0 else RED if s['pct'] < 0 else WHITE parts.append(font.render(s['name'] + ' ', True, GRAY)) parts.append(font.render(s['price'] + ' ' + s['pct_str'], True, col)) if i < len(items) - 1: parts.append(sep) total_w = sum(p.get_width() for p in parts) h = font.get_height() surf = pygame.Surface((total_w, h), pygame.SRCALPHA) x = 0 for p in parts: surf.blit(p, (x, 0)) x += p.get_width() _ticker_cache[key] = (data_key, surf) return surf def draw_ticker(surface, surf, x, y, w, speed=50.0): """Scroll surf right-to-left in the region (x, y, w, surf.height).""" if surf is None: return sw = surf.get_width() total = sw + 120 # gap between end of text and next repeat off = (_time.time() * speed) % total bx = int(x + w - off) old = surface.get_clip() surface.set_clip(pygame.Rect(x, y, w, surf.get_height())) surface.blit(surf, (bx, y)) surface.blit(surf, (bx + total, y)) # seamless second copy surface.set_clip(old) # ═══════════════════════════════════════════════════════════════════════════════ # WEATHER PANEL # ═══════════════════════════════════════════════════════════════════════════════ _WEATHER_COLS = 5 _WEATHER_COL_W = 84 def draw_weather(surface, fonts, x, y): with _weather_lock: all_data = list(_weather_all) th = fonts['tiny'].get_height() row = th + 2 town_h = fonts['medium'].get_height() def _trunc(s, n=13): 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 all_data: surface.blit(fonts['medium'].render('Weather loading…', True, DIM_GRAY), (x, y)) return idx = int(_time.time() / 10) % len(all_data) data = all_data[idx] week = data.get('week', []) hours = data.get('hours', []) town = data.get('town', '') surface.blit(fonts['medium'].render(town, True, GRAY), (x, y)) _col(week, y + town_h + 4, lambda d: 'Today' if d['day'] == 'Today' else d['day'][:3], lambda d: f"{d['high']}°/{d['low']}°", lambda d: _trunc(d['desc'])) if hours: _col(hours, y + town_h + 4 + 3*row + 8, lambda h: h['label'], lambda h: f"{h['temp']}°", lambda h: _trunc(h['desc'])) # ═══════════════════════════════════════════════════════════════════════════════ # FONT HELPER # ═══════════════════════════════════════════════════════════════════════════════ def _load_font(size, bold=False): 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 # ═══════════════════════════════════════════════════════════════════════════════ 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), 'medium': _load_font(18), 'small': _load_font(13), 'tiny': _load_font(11), } # ── Layout ──────────────────────────────────────────────────────────────── 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 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 weather_y = clock_y - fonts['medium'].get_height() - 4 # Equal blank space either side of the face face_gap = weather_x - (face_cx + face_rx) # gap on the right face_left = face_cx - face_rx - face_gap # mirrored left boundary # GREEN BOX — stocks: starts just after the time+seconds on the time row _time_row_w = fonts['large'].size('23:59')[0] + 2 + fonts['small'].size('59')[0] stocks_ticker_x = 20 + _time_row_w + 12 stocks_ticker_y = clock_y + fonts['small'].get_height() + 4 # same row as time stocks_ticker_w = face_left - stocks_ticker_x # RED BOX — news: full left zone, just below the clock block news_ticker_x = 20 news_ticker_w = face_left - news_ticker_x news_ticker_y = clock_y + clock_block_h + 8 # ── Background threads ──────────────────────────────────────────────────── threading.Thread(target=_news_worker, daemon=True).start() threading.Thread(target=_stocks_worker, daemon=True).start() try: resolved = [_geocode(city) for city in _LOCATIONS] threading.Thread(target=_weather_worker, args=(resolved,), daemon=True).start() except Exception: pass # ── Face image ──────────────────────────────────────────────────────────── _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) # Face 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 (top-left) draw_clock(screen, fonts, 20, clock_y) # News ticker — RED BOX: below clock, full left-of-face width draw_ticker(screen, _build_news_surf(fonts['small']), news_ticker_x, news_ticker_y, news_ticker_w, speed=40.0) # Stocks ticker — GREEN BOX: right of clock digits, same row draw_ticker(screen, _build_stocks_surf(fonts['small']), stocks_ticker_x, stocks_ticker_y, stocks_ticker_w, speed=50.0) # Weather (top-right) draw_weather(screen, fonts, weather_x, weather_y) pygame.display.flip() pg_clock.tick(30) # bumped to 30 fps for smooth scrolling if __name__ == '__main__': main()