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-17 09:46:43 +01:00
|
|
|
News via BBC RSS (no API key).
|
|
|
|
|
Stocks via Yahoo Finance public quote API (no API key).
|
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-17 09:46:43 +01:00
|
|
|
import xml.etree.ElementTree as ET
|
2026-04-16 10:31:51 +01:00
|
|
|
from datetime import datetime
|
|
|
|
|
|
2026-04-18 05:25:28 +01:00
|
|
|
|
2026-04-16 10:31:51 +01:00
|
|
|
_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-18 05:25:28 +01:00
|
|
|
_parser.add_argument('--mic', type=int, default=0,
|
|
|
|
|
help='Microphone device index (default 0)')
|
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-17 09:46:43 +01:00
|
|
|
# ── Locations to cycle through (weather) ─────────────────────────────────────
|
2026-04-16 20:15:19 +01:00
|
|
|
_LOCATIONS = ['Poole', 'Portsmouth', 'Besancon', 'Paris', 'Gorinchem']
|
|
|
|
|
|
2026-04-17 09:46:43 +01:00
|
|
|
# ── 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'),
|
|
|
|
|
]
|
|
|
|
|
|
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)
|
2026-04-17 09:46:43 +01:00
|
|
|
GREEN = ( 80, 200, 80)
|
|
|
|
|
RED = (200, 80, 80)
|
2026-04-16 19:25:38 +01:00
|
|
|
|
2026-04-17 09:46:43 +01:00
|
|
|
# ── WMO weather codes ─────────────────────────────────────────────────────────
|
2026-04-16 19:25:38 +01:00
|
|
|
_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-17 09:46:43 +01:00
|
|
|
# ── 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
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
2026-04-16 20:15:19 +01:00
|
|
|
_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-17 09:46:43 +01:00
|
|
|
data = json.loads(_get(url))
|
|
|
|
|
r = data['results'][0]
|
2026-04-16 20:15:19 +01:00
|
|
|
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-17 09:46:43 +01:00
|
|
|
'&timezone=auto&forecast_days=7'
|
2026-04-16 19:25:38 +01:00
|
|
|
)
|
2026-04-17 09:46:43 +01:00
|
|
|
data = json.loads(_get(url))
|
2026-04-16 19:25:38 +01:00
|
|
|
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-18 05:25:28 +01:00
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# WAKE WORD
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
_OWW_MODEL = '/home/dfr84/Python/JARVIS/Jarvis.onnx'
|
|
|
|
|
_OWW_THRESHOLD = 0.5
|
|
|
|
|
_OWW_CHUNK = 1280
|
|
|
|
|
|
|
|
|
|
# Shared wake state — written by audio thread, read by render thread
|
|
|
|
|
_wake: dict = {'active': False, 'detected_at': 0.0}
|
|
|
|
|
|
|
|
|
|
def _wake_worker() -> None:
|
|
|
|
|
try:
|
|
|
|
|
import pyaudio
|
|
|
|
|
import numpy as np
|
|
|
|
|
from openwakeword.model import Model
|
|
|
|
|
except ImportError as e:
|
|
|
|
|
print(f'[WAKE] Missing dependency: {e} — wake word disabled')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
model = Model(wakeword_model_paths=[_OWW_MODEL])
|
|
|
|
|
|
|
|
|
|
audio = pyaudio.PyAudio()
|
|
|
|
|
dev_info = audio.get_device_info_by_index(_args.mic)
|
|
|
|
|
n_ch = int(dev_info['maxInputChannels'])
|
|
|
|
|
native_hz = int(dev_info['defaultSampleRate'])
|
|
|
|
|
target_hz = 16000
|
|
|
|
|
# frames_per_buffer scaled so we always get ~_OWW_CHUNK samples at 16 kHz
|
|
|
|
|
buf_frames = int(_OWW_CHUNK * native_hz / target_hz)
|
|
|
|
|
|
|
|
|
|
stream = audio.open(
|
|
|
|
|
format=pyaudio.paInt16,
|
|
|
|
|
channels=n_ch,
|
|
|
|
|
rate=native_hz,
|
|
|
|
|
input=True,
|
|
|
|
|
input_device_index=_args.mic,
|
|
|
|
|
frames_per_buffer=buf_frames,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
data = stream.read(buf_frames, exception_on_overflow=False)
|
|
|
|
|
audio_data = np.frombuffer(data, dtype=np.int16)
|
|
|
|
|
if n_ch > 1:
|
|
|
|
|
audio_data = audio_data.reshape(-1, n_ch)[:, 0]
|
|
|
|
|
# resample to 16 kHz
|
|
|
|
|
if native_hz != target_hz:
|
|
|
|
|
ratio = target_hz / native_hz
|
|
|
|
|
new_len = int(len(audio_data) * ratio)
|
|
|
|
|
indices = np.round(np.linspace(0, len(audio_data) - 1, new_len)).astype(int)
|
|
|
|
|
audio_data = audio_data[indices]
|
|
|
|
|
prediction = model.predict(audio_data)
|
|
|
|
|
for score in prediction.values():
|
|
|
|
|
if score >= _OWW_THRESHOLD:
|
|
|
|
|
_wake['active'] = True
|
|
|
|
|
_wake['detected_at'] = _time.time()
|
|
|
|
|
break
|
|
|
|
|
except Exception as e:
|
|
|
|
|
import traceback
|
|
|
|
|
print(f'[WAKE] {type(e).__name__}: {e}')
|
|
|
|
|
traceback.print_exc()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lerp_color(c1: tuple, c2: tuple, t: float) -> tuple:
|
|
|
|
|
return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
|
|
|
|
|
|
|
|
|
|
_FACE_GREEN = ( 40, 200, 80)
|
|
|
|
|
_FACE_BLUE = ( 40, 80, 220)
|
|
|
|
|
_WAKE_DURATION = 8.0 # seconds before returning to idle
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 09:46:43 +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
|
|
|
|
2026-04-18 05:25:28 +01:00
|
|
|
def draw_wireframe_face(surface, cx, cy, rx, ry,
|
|
|
|
|
col_wire=WIRE_COL, col_eye=WIRE_EYE):
|
2026-04-16 19:25:38 +01:00
|
|
|
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)
|
|
|
|
|
|
2026-04-18 05:25:28 +01:00
|
|
|
def draw(pts, col=col_wire):
|
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])
|
2026-04-18 05:25:28 +01:00
|
|
|
pygame.draw.lines(surface, col_wire, False, [sc(-1.0,-1.0), sc(1.0,-1.0)], 1)
|
|
|
|
|
pygame.draw.lines(surface, col_wire, 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)]
|
2026-04-18 05:25:28 +01:00
|
|
|
pygame.draw.lines(surface, col_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-17 09:46:43 +01:00
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# CLOCK
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
2026-04-16 10:31:51 +01:00
|
|
|
|
2026-04-17 09:46:43 +01:00
|
|
|
def draw_clock(surface, fonts, x, y):
|
2026-04-16 19:25:38 +01:00
|
|
|
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-17 09:46:43 +01:00
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# 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
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
2026-04-16 19:25:38 +01:00
|
|
|
|
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
|
|
|
|
2026-04-17 09:46:43 +01:00
|
|
|
def draw_weather(surface, fonts, x, y):
|
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-17 09:46:43 +01:00
|
|
|
idx = int(_time.time() / 10) % len(all_data)
|
|
|
|
|
data = all_data[idx]
|
2026-04-16 20:15:19 +01:00
|
|
|
week = data.get('week', [])
|
|
|
|
|
hours = data.get('hours', [])
|
|
|
|
|
town = data.get('town', '')
|
|
|
|
|
|
|
|
|
|
surface.blit(fonts['medium'].render(town, True, GRAY), (x, y))
|
|
|
|
|
|
2026-04-16 19:44:48 +01:00
|
|
|
_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']))
|
|
|
|
|
|
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-17 09:46:43 +01:00
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# FONT HELPER
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
2026-04-16 10:31:51 +01:00
|
|
|
|
2026-04-17 09:46:43 +01:00
|
|
|
def _load_font(size, bold=False):
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 09:46:43 +01:00
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
# MAIN
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
2026-04-16 10:31:51 +01:00
|
|
|
|
|
|
|
|
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-17 09:46:43 +01:00
|
|
|
# ── Layout ────────────────────────────────────────────────────────────────
|
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-17 09:46:43 +01:00
|
|
|
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()
|
2026-04-18 05:25:28 +01:00
|
|
|
threading.Thread(target=_wake_worker, daemon=True).start()
|
2026-04-17 09:46:43 +01:00
|
|
|
|
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
|
|
|
|
|
|
2026-04-17 09:46:43 +01:00
|
|
|
# ── Face image ────────────────────────────────────────────────────────────
|
2026-04-16 19:25:38 +01:00
|
|
|
_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))))
|
|
|
|
|
|
2026-04-18 05:25:28 +01:00
|
|
|
# Reusable surface for PNG tinting (fill + BLEND_MULT each frame)
|
|
|
|
|
face_tint_surf = pygame.Surface(face_img.get_size()) if face_img else None
|
|
|
|
|
|
2026-04-16 19:25:38 +01:00
|
|
|
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
|
|
|
|
2026-04-18 05:25:28 +01:00
|
|
|
# ── Wake-word face colour ─────────────────────────────────────────────
|
|
|
|
|
if _wake['active']:
|
|
|
|
|
elapsed = _time.time() - _wake['detected_at']
|
|
|
|
|
if elapsed >= _WAKE_DURATION:
|
|
|
|
|
_wake['active'] = False
|
|
|
|
|
face_color = None
|
|
|
|
|
else:
|
|
|
|
|
t = (math.sin(elapsed * math.pi * 2.0) + 1.0) / 2.0 # 0→1, 1 Hz
|
|
|
|
|
face_color = _lerp_color(_FACE_GREEN, _FACE_BLUE, t)
|
|
|
|
|
else:
|
|
|
|
|
face_color = None
|
|
|
|
|
|
2026-04-16 10:31:51 +01:00
|
|
|
screen.fill(BLACK)
|
2026-04-16 19:25:38 +01:00
|
|
|
|
2026-04-18 05:25:28 +01:00
|
|
|
# Face — tinted when wake active, normal otherwise
|
2026-04-16 10:31:51 +01:00
|
|
|
if face_img:
|
2026-04-18 05:25:28 +01:00
|
|
|
if face_color and face_tint_surf:
|
|
|
|
|
face_tint_surf.fill(face_color)
|
|
|
|
|
face_tint_surf.blit(face_img, (0, 0),
|
|
|
|
|
special_flags=pygame.BLEND_MULT)
|
|
|
|
|
screen.blit(face_tint_surf,
|
|
|
|
|
face_tint_surf.get_rect(center=(face_cx, face_cy)),
|
|
|
|
|
special_flags=pygame.BLEND_ADD)
|
|
|
|
|
else:
|
|
|
|
|
screen.blit(face_img,
|
|
|
|
|
face_img.get_rect(center=(face_cx, face_cy)),
|
|
|
|
|
special_flags=pygame.BLEND_ADD)
|
2026-04-16 10:31:51 +01:00
|
|
|
else:
|
2026-04-18 05:25:28 +01:00
|
|
|
cw = face_color or WIRE_COL
|
|
|
|
|
ce = face_color or WIRE_EYE
|
|
|
|
|
draw_wireframe_face(screen, face_cx, face_cy, face_rx, face_ry,
|
|
|
|
|
col_wire=cw, col_eye=ce)
|
2026-04-16 19:25:38 +01:00
|
|
|
|
2026-04-17 09:46:43 +01:00
|
|
|
# Clock (top-left)
|
2026-04-16 19:25:38 +01:00
|
|
|
draw_clock(screen, fonts, 20, clock_y)
|
2026-04-17 09:46:43 +01:00
|
|
|
|
|
|
|
|
# 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)
|
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-17 09:46:43 +01:00
|
|
|
pg_clock.tick(30) # bumped to 30 fps for smooth scrolling
|
2026-04-16 10:31:51 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
main()
|