From bfa7f988c4f4f594bafacc628ec37868b1e2627b Mon Sep 17 00:00:00 2001 From: David Rice Date: Fri, 17 Apr 2026 09:46:43 +0100 Subject: [PATCH] Changes --- display.py | 287 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 251 insertions(+), 36 deletions(-) diff --git a/display.py b/display.py index e093bb1..8224b46 100644 --- a/display.py +++ b/display.py @@ -8,7 +8,8 @@ Renders the JARVIS UI overlay via X11. Kiosk: DISPLAY=:0 python display.py --fullscreen Weather via Open-Meteo (free, no API key). -Cycles through _LOCATIONS every 5 seconds. +News via BBC RSS (no API key). +Stocks via Yahoo Finance public quote API (no API key). """ import os @@ -19,6 +20,7 @@ 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) @@ -32,9 +34,27 @@ _args, _ = _parser.parse_known_args() import pygame -# ── Locations to cycle through ──────────────────────────────────────────────── +# ── 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) @@ -42,8 +62,10 @@ 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 (Open-Meteo) ──────────────────────────────────────────── +# ── WMO weather codes ───────────────────────────────────────────────────────── _WMO = { 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', 45: 'Fog', 48: 'Freezing fog', @@ -55,32 +77,115 @@ _WMO = { 95: 'Thunderstorm', 96: 'Thunderstorm + hail', 99: 'Thunderstorm + hail', } -# ── Weather state — one dict per location, updated by background thread ─────── +# ── 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') - with urllib.request.urlopen(url, timeout=10) as r: - data = json.loads(r.read()) - r = data['results'][0] + 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' + '&timezone=auto&forecast_days=7' ) - with urllib.request.urlopen(url, timeout=10) as r: - data = json.loads(r.read()) - + data = json.loads(_get(url)) daily = data['daily'] hourly = data['hourly'] @@ -110,7 +215,6 @@ def _fetch_one(lat: float, lon: float) -> dict: return {'week': week, 'hours': hours} - def _weather_worker(locations: list[tuple[float, float, str]]) -> None: while True: fresh = [] @@ -127,16 +231,16 @@ def _weather_worker(locations: list[tuple[float, float, str]]) -> None: _time.sleep(1800) -# ── Wireframe face ──────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ +# 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: +def draw_wireframe_face(surface, cx, cy, rx, ry): N_H, N_V, PTS = 13, 11, 40 def sc(xf, t): @@ -180,9 +284,11 @@ def draw_wireframe_face(surface: pygame.Surface, draw([(cx+inner, e_btm), (cx+nose_w, nose_y)]) -# ── Clock ───────────────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ +# CLOCK +# ═══════════════════════════════════════════════════════════════════════════════ -def draw_clock(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None: +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) @@ -193,13 +299,84 @@ def draw_clock(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None: ty + t_surf.get_height() - s_surf.get_height() - 8)) -# ── Weather panel ───────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ +# 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: pygame.Surface, fonts: dict, x: int, y: int) -> None: +def draw_weather(surface, fonts, x, y): with _weather_lock: all_data = list(_weather_all) @@ -221,23 +398,20 @@ def draw_weather(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None: 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] + idx = int(_time.time() / 10) % 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)) - # 5-day section _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'])) - # 5-hour section if hours: _col(hours, y + town_h + 4 + 3*row + 8, @@ -246,9 +420,11 @@ def draw_weather(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None: lambda h: _trunc(h['desc'])) -# ── Font helper ─────────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ +# FONT HELPER +# ═══════════════════════════════════════════════════════════════════════════════ -def _load_font(size: int, bold: bool = False) -> pygame.font.Font: +def _load_font(size, bold=False): for name in ('DejaVuSans', 'FreeSans', 'LiberationSans', 'Helvetica', 'Arial', None): try: f = pygame.font.SysFont(name, size, bold=bold) @@ -259,7 +435,9 @@ def _load_font(size: int, bold: bool = False) -> pygame.font.Font: return pygame.font.Font(None, size) -# ── Main loop ───────────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════════════════════ def main() -> None: pygame.init() @@ -281,6 +459,7 @@ def main() -> None: '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 @@ -288,16 +467,36 @@ def main() -> None: 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 - # Geocode all locations and start background weather thread + 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 @@ -319,17 +518,33 @@ def main() -> None: 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(10) + pg_clock.tick(30) # bumped to 30 fps for smooth scrolling if __name__ == '__main__':