Changes
This commit is contained in:
277
display.py
277
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())
|
||||
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)
|
||||
|
||||
@@ -227,17 +404,14 @@ def draw_weather(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None:
|
||||
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
|
||||
# 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__':
|
||||
|
||||
Reference in New Issue
Block a user