Files
JARVIS/display.py
David Rice bfa7f988c4 Changes
2026-04-17 09:46:43 +01:00

552 lines
22 KiB
Python

#!/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()