Files
JARVIS/display.py

337 lines
12 KiB
Python
Raw Normal View History

2026-04-16 10:31:51 +01:00
#!/usr/bin/env python3
"""
JARVIS Display
2026-04-16 18:56:21 +01:00
Renders the JARVIS UI overlay via X11.
2026-04-16 10:31:51 +01:00
2026-04-16 18:56:21 +01:00
Development: python display.py
Kiosk: DISPLAY=:0 python display.py --fullscreen
2026-04-16 19:25:38 +01:00
Weather via Open-Meteo (free, no API key).
2026-04-16 20:15:19 +01:00
Cycles through _LOCATIONS every 5 seconds.
2026-04-16 10:31:51 +01:00
"""
import os
2026-04-16 19:25:38 +01:00
import json
2026-04-16 10:31:51 +01:00
import math
2026-04-16 19:25:38 +01:00
import threading
import time as _time
2026-04-16 10:31:51 +01:00
import argparse
2026-04-16 19:25:38 +01:00
import urllib.request
import urllib.parse
2026-04-16 10:31:51 +01:00
from datetime import datetime
_parser = argparse.ArgumentParser(description='JARVIS display', add_help=True)
2026-04-16 19:25:38 +01:00
_parser.add_argument('--fullscreen', '-fs', action='store_true',
help='Run fullscreen under X11/Wayland')
2026-04-16 10:31:51 +01:00
_parser.add_argument('--width', type=int, default=1280,
2026-04-16 18:56:21 +01:00
help='Screen width (default 1280)')
2026-04-16 10:31:51 +01:00
_parser.add_argument('--height', type=int, default=800,
2026-04-16 18:56:21 +01:00
help='Screen height (default 800)')
2026-04-16 10:31:51 +01:00
_args, _ = _parser.parse_known_args()
2026-04-16 18:56:21 +01:00
import pygame
2026-04-16 10:31:51 +01:00
2026-04-16 20:15:19 +01:00
# ── Locations to cycle through ────────────────────────────────────────────────
_LOCATIONS = ['Poole', 'Portsmouth', 'Besancon', 'Paris', 'Gorinchem']
2026-04-16 10:31:51 +01:00
# ── Colour palette ───────────────────────────────────────────────────────────
BLACK = ( 0, 0, 0)
WHITE = (255, 255, 255)
GRAY = (160, 160, 160)
DIM_GRAY = ( 80, 80, 80)
2026-04-16 19:25:38 +01:00
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',
}
2026-04-16 20:15:19 +01:00
# ── Weather state — one dict per location, updated by background thread ───────
_weather_all: list[dict] = []
2026-04-16 19:25:38 +01:00
_weather_lock = threading.Lock()
2026-04-16 20:15:19 +01:00
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')
2026-04-16 19:25:38 +01:00
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
2026-04-16 20:15:19 +01:00
r = data['results'][0]
return r['latitude'], r['longitude'], r['name']
2026-04-16 19:25:38 +01:00
2026-04-16 20:15:19 +01:00
def _fetch_one(lat: float, lon: float) -> dict:
2026-04-16 19:25:38 +01:00
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'
2026-04-16 20:15:19 +01:00
'&timezone=auto'
2026-04-16 19:25:38 +01:00
'&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'),
'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']):
2026-04-16 20:00:38 +01:00
if len(hours) >= 5:
break
2026-04-16 19:25:38 +01:00
t = datetime.strptime(ts, '%Y-%m-%dT%H:%M')
2026-04-16 20:00:38 +01:00
if t <= now:
2026-04-16 19:25:38 +01:00
continue
hours.append({
'label': t.strftime('%-I%p').lower(),
'temp': round(hourly['temperature_2m'][i]),
2026-04-16 19:44:48 +01:00
'desc': _WMO.get(hourly['weather_code'][i], ''),
2026-04-16 19:25:38 +01:00
})
2026-04-16 20:15:19 +01:00
return {'week': week, 'hours': hours}
2026-04-16 19:25:38 +01:00
2026-04-16 20:15:19 +01:00
def _weather_worker(locations: list[tuple[float, float, str]]) -> None:
2026-04-16 19:25:38 +01:00
while True:
2026-04-16 20:15:19 +01:00
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)
2026-04-16 19:25:38 +01:00
_time.sleep(1800)
2026-04-16 10:31:51 +01:00
2026-04-16 19:25:38 +01:00
# ── Wireframe face ────────────────────────────────────────────────────────────
2026-04-16 10:31:51 +01:00
def _hw(t: float, rx: float) -> float:
if t <= 0.0:
return rx * math.sqrt(max(0.0, 1.0 - t * t))
2026-04-16 19:25:38 +01:00
return rx * math.sqrt(max(0.0, 1.0 - t * t)) * (1.0 - 0.30 * t)
2026-04-16 10:31:51 +01:00
def draw_wireframe_face(surface: pygame.Surface,
2026-04-16 19:25:38 +01:00
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):
2026-04-16 10:31:51 +01:00
if len(pts) >= 2:
2026-04-16 19:25:38 +01:00
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)
2026-04-16 10:31:51 +01:00
2026-04-16 19:25:38 +01:00
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)])
2026-04-16 10:31:51 +01:00
for i in range(1, N_V):
2026-04-16 19:25:38 +01:00
xf = -1.0 + 2.0*i/N_V
2026-04-16 10:31:51 +01:00
draw([sc(xf, t) for t in t_vals])
2026-04-16 19:25:38 +01:00
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)
2026-04-16 10:31:51 +01:00
for sign in (-1, +1):
ecx = int(cx + sign * eye_xf * eye_w)
ecy = int(cy + eye_t * ry)
2026-04-16 19:25:38 +01:00
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)
2026-04-16 10:31:51 +01:00
2026-04-16 19:25:38 +01:00
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)])
2026-04-16 10:31:51 +01:00
2026-04-16 19:25:38 +01:00
# ── Clock ─────────────────────────────────────────────────────────────────────
2026-04-16 10:31:51 +01:00
2026-04-16 19:25:38 +01:00
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))
2026-04-16 10:31:51 +01:00
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))
2026-04-16 19:25:38 +01:00
# ── Weather panel ─────────────────────────────────────────────────────────────
2026-04-16 19:44:48 +01:00
_WEATHER_COLS = 5
2026-04-16 20:15:19 +01:00
_WEATHER_COL_W = 84
2026-04-16 19:44:48 +01:00
def draw_weather(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None:
2026-04-16 19:25:38 +01:00
with _weather_lock:
2026-04-16 20:15:19 +01:00
all_data = list(_weather_all)
2026-04-16 19:25:38 +01:00
2026-04-16 20:15:19 +01:00
th = fonts['tiny'].get_height()
row = th + 2
town_h = fonts['medium'].get_height()
2026-04-16 19:44:48 +01:00
2026-04-16 19:54:08 +01:00
def _trunc(s, n=13):
2026-04-16 19:44:48 +01:00
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))
2026-04-16 10:31:51 +01:00
2026-04-16 20:15:19 +01:00
if not all_data:
surface.blit(fonts['medium'].render('Weather loading…', True, DIM_GRAY), (x, y))
2026-04-16 19:25:38 +01:00
return
2026-04-16 10:31:51 +01:00
2026-04-16 20:15:19 +01:00
idx = int(_time.time() / 5) % len(all_data)
data = all_data[idx]
week = data.get('week', [])
hours = data.get('hours', [])
town = data.get('town', '')
# Town name
surface.blit(fonts['medium'].render(town, True, GRAY), (x, y))
2026-04-16 19:44:48 +01:00
# 5-day section
_col(week,
2026-04-16 20:00:38 +01:00
y + town_h + 4,
2026-04-16 19:44:48 +01:00
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
2026-04-16 19:25:38 +01:00
if hours:
2026-04-16 20:15:19 +01:00
_col(hours,
2026-04-16 20:00:38 +01:00
y + town_h + 4 + 3*row + 8,
2026-04-16 19:44:48 +01:00
lambda h: h['label'],
lambda h: f"{h['temp']}°",
lambda h: _trunc(h['desc']))
2026-04-16 19:25:38 +01:00
2026-04-16 10:31:51 +01:00
# ── Font helper ───────────────────────────────────────────────────────────────
def _load_font(size: int, bold: bool = False) -> pygame.font.Font:
2026-04-16 19:25:38 +01:00
for name in ('DejaVuSans', 'FreeSans', 'LiberationSans', 'Helvetica', 'Arial', None):
2026-04-16 10:31:51 +01:00
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
2026-04-16 18:56:21 +01:00
if _args.fullscreen:
2026-04-16 10:31:51 +01:00
screen = pygame.display.set_mode((W, H), pygame.FULLSCREEN | pygame.NOFRAME)
else:
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption('JARVIS')
2026-04-16 18:03:39 +01:00
pygame.mouse.set_visible(False)
2026-04-16 10:31:51 +01:00
fonts = {
2026-04-16 20:08:28 +01:00
'large': _load_font(38),
'medium': _load_font(18),
'small': _load_font(13),
'tiny': _load_font(11),
2026-04-16 10:31:51 +01:00
}
2026-04-16 19:25:38 +01:00
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
2026-04-16 19:44:48 +01:00
weather_x = W - _WEATHER_COLS * _WEATHER_COL_W - 15
2026-04-16 20:15:19 +01:00
weather_y = clock_y - fonts['medium'].get_height() - 4
2026-04-16 19:25:38 +01:00
2026-04-16 20:15:19 +01:00
# Geocode all locations and start background weather thread
2026-04-16 19:25:38 +01:00
try:
2026-04-16 20:15:19 +01:00
resolved = [_geocode(city) for city in _LOCATIONS]
threading.Thread(target=_weather_worker, args=(resolved,), daemon=True).start()
2026-04-16 19:25:38 +01:00
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()
2026-04-16 10:31:51 +01:00
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
2026-04-16 19:25:38 +01:00
pygame.quit(); return
if event.type == pygame.KEYDOWN and event.key in (pygame.K_ESCAPE, pygame.K_q):
pygame.quit(); return
2026-04-16 10:31:51 +01:00
screen.fill(BLACK)
2026-04-16 19:25:38 +01:00
2026-04-16 10:31:51 +01:00
if face_img:
2026-04-16 19:25:38 +01:00
screen.blit(face_img, face_img.get_rect(center=(face_cx, face_cy)),
2026-04-16 10:31:51 +01:00
special_flags=pygame.BLEND_ADD)
else:
draw_wireframe_face(screen, face_cx, face_cy, face_rx, face_ry)
2026-04-16 19:25:38 +01:00
draw_clock(screen, fonts, 20, clock_y)
2026-04-16 20:15:19 +01:00
draw_weather(screen, fonts, weather_x, weather_y)
2026-04-16 10:31:51 +01:00
pygame.display.flip()
2026-04-16 19:25:38 +01:00
pg_clock.tick(10)
2026-04-16 10:31:51 +01:00
if __name__ == '__main__':
main()