diff --git a/display.py b/display.py index 78cec3f..dc19090 100644 --- a/display.py +++ b/display.py @@ -6,21 +6,30 @@ 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). +Location set by --location postcode (default BH8 8JZ). """ import os +import json import math +import threading +import time as _time import argparse +import urllib.request +import urllib.parse 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('--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)') +_parser.add_argument('--location', type=str, default='BH8 8JZ', + help='UK postcode for weather (default BH8 8JZ)') _args, _ = _parser.parse_known_args() import pygame @@ -30,142 +39,192 @@ BLACK = ( 0, 0, 0) WHITE = (255, 255, 255) GRAY = (160, 160, 160) DIM_GRAY = ( 80, 80, 80) -WIRE_COL = ( 58, 58, 58) # subdued wireframe line colour +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', +} + +# ── Weather state (updated by background thread) ────────────────────────────── +_weather: dict = {} # keys: 'week' (list), 'hours' (list), 'location' (str) +_weather_lock = threading.Lock() + + +def _postcode_to_latlon(postcode: str) -> tuple[float, float]: + url = 'https://api.postcodes.io/postcodes/' + urllib.parse.quote(postcode.replace(' ', '')) + with urllib.request.urlopen(url, timeout=10) as r: + data = json.loads(r.read()) + return data['result']['latitude'], data['result']['longitude'] + + +def _fetch_weather(lat: float, lon: float) -> None: + 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=Europe%2FLondon' + '&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'), + 'date': d.strftime('%-d %b'), + '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']): + t = datetime.strptime(ts, '%Y-%m-%dT%H:%M') + if t < now or t.date() != now.date(): + continue + hours.append({ + 'label': t.strftime('%-I%p').lower(), + 'temp': round(hourly['temperature_2m'][i]), + }) + + with _weather_lock: + _weather['week'] = week + _weather['hours'] = hours + + +def _weather_worker(lat: float, lon: float) -> None: + while True: + try: + _fetch_weather(lat, lon) + except Exception: + pass + _time.sleep(1800) + # ── Wireframe face ──────────────────────────────────────────────────────────── -# Pure 2-D face drawing. Face shape is defined by an explicit width profile; -# eye sockets, nose bridge and chin are drawn as distinct features so the -# brain immediately reads "face" rather than a geometric solid. - -WIRE_EYE = ( 95, 95, 95) # slightly brighter for eye/feature lines def _hw(t: float, rx: float) -> float: - """ - Face half-width in pixels at normalised height t. - t = -1 → crown (top) - t = 0 → cheek level (widest, ~= rx) - t = +1 → chin tip - Upper half: rounded ellipse. - Lower half: faster taper toward chin. - """ if t <= 0.0: return rx * math.sqrt(max(0.0, 1.0 - t * t)) - else: - return rx * math.sqrt(max(0.0, 1.0 - t * t)) * (1.0 - 0.30 * 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: - """ - Draw a 2-D face wireframe centred at (cx, cy). - Coordinate system: t ∈ [−1, +1] (−1=crown, 0=cheeks, +1=chin) - x_fraction ∈ [−1, +1] (fraction of half-width at t) - """ - N_H = 13 # horizontal grid lines - N_V = 11 # vertical grid lines (including edges) - PTS = 40 # interpolation steps per curve + cx: int, cy: int, rx: float, ry: float) -> None: + N_H, N_V, PTS = 13, 11, 40 - # Helpers ───────────────────────────────────────────────────────────────── - def sc(xf: float, t: float) -> tuple[int, int]: - """xf (fraction of half-width) + height t → screen pixel.""" - w = _hw(t, rx) - # Slight forward bow: face curves toward viewer at cheek level - bow = int(0.06 * w * (1.0 - t * t) * (1.0 - xf * xf)) - return (int(cx + xf * w), int(cy + t * ry) - bow) + 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, close=False, col=WIRE_COL): + def draw(pts, col=WIRE_COL): if len(pts) >= 2: - pygame.draw.lines(surface, col, close, pts, 1) + pygame.draw.lines(surface, col, False, pts, 1) - # Face outline (left and right silhouette) ──────────────────────────────── - t_vals = [(-1.0 + 2.0 * i / (PTS - 1)) for i in range(PTS)] - draw([sc(-1.0, t) for t in t_vals]) # left edge - draw([sc( 1.0, t) for t in t_vals]) # right edge - # Crown arc (straight line across the very top) - draw([sc(-1.0, -1.0), sc(1.0, -1.0)]) - # Chin arc - draw([sc(-0.6, 1.0), sc(0.0, 1.02), sc(0.6, 1.0)]) + 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) - # Horizontal grid lines ─────────────────────────────────────────────────── 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)]) - - # Vertical grid lines ───────────────────────────────────────────────────── + 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 + xf = -1.0 + 2.0*i/N_V draw([sc(xf, t) for t in t_vals]) - # ── Eye sockets ─────────────────────────────────────────────────────────── - # Each eye is a small ellipse drawn in a slightly brighter colour so it - # immediately reads as a facial feature. - eye_t = -0.38 # height: upper face (~35% from crown) - eye_xf = 0.35 # lateral offset (fraction of half-width at eye_t) - eye_w = _hw(eye_t, rx) # face half-width at eye level - e_rx = int(eye_w * 0.24) - e_ry = int(ry * 0.09) - + 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) - eye_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(28) - ] - draw(eye_pts + [eye_pts[0]], col=WIRE_EYE) + 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) - # ── Nose bridge ─────────────────────────────────────────────────────────── - # Two short lines running from inner eye corners down to nose tip - inner = int(eye_w * 0.12) - e_btm = int(cy + eye_t * ry) + e_ry - nose_y = int(cy + 0.08 * ry) + 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)], col=WIRE_COL) - draw([(cx + inner, e_btm), (cx + nose_w, nose_y)], col=WIRE_COL) + draw([(cx-inner, e_btm), (cx-nose_w, nose_y)]) + draw([(cx+inner, e_btm), (cx+nose_w, nose_y)]) -# ── Clock / date ───────────────────────────────────────────────────────────── +# ── Clock ───────────────────────────────────────────────────────────────────── -def draw_clock(surface: pygame.Surface, fonts: dict, - x: int, y: int) -> None: - now = datetime.now() - date_str = now.strftime('%A, %B %-d, %Y') - time_str = now.strftime('%-H:%M') - secs_str = now.strftime('%S') - - # Date row - surface.blit(fonts['small'].render(date_str, True, GRAY), (x, y)) - - # Large time + superscript seconds - t_surf = fonts['large'].render(time_str, True, WHITE) - s_surf = fonts['small'].render(secs_str, True, DIM_GRAY) +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)) 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)) -# ── Status line ─────────────────────────────────────────────────────────────── +# ── Weather panel ───────────────────────────────────────────────────────────── -def draw_status(surface: pygame.Surface, fonts: dict, - text: str, cx: int, y: int) -> None: - surf = fonts['small'].render(text, True, GRAY) - surface.blit(surf, (cx - surf.get_width() // 2, y)) +def draw_weather(surface: pygame.Surface, fonts: dict, x: int, y: int) -> None: + with _weather_lock: + week = list(_weather.get('week', [])) + hours = list(_weather.get('hours', [])) + + sh = fonts['small'].get_height() + lh = fonts['large'].get_height() + + if not week: + surface.blit(fonts['small'].render('Weather loading…', True, DIM_GRAY), (x, y)) + return + + # Rotate through the 7 days every 4 seconds + day = week[int(_time.time() / 4) % len(week)] + + surface.blit(fonts['small'].render(f"{day['day']} {day['date']}", True, GRAY), (x, y)) + t_surf = fonts['large'].render(f"{day['high']}° / {day['low']}°", True, WHITE) + ty = y + sh + 4 + surface.blit(t_surf, (x, ty)) + surface.blit(fonts['small'].render(day['desc'], True, GRAY), (x, ty + lh + 3)) + + # Hourly strip for today — next 8 hours, displayed as "2pm 14° 3pm 13° …" + if hours: + strip_y = ty + lh + 3 + sh + 8 + col_w = 52 + for i, h in enumerate(hours[:8]): + hx = x + i * col_w + surface.blit(fonts['small'].render(h['label'], True, DIM_GRAY), (hx, strip_y)) + surface.blit(fonts['small'].render(f"{h['temp']}°", True, GRAY), + (hx, strip_y + sh + 1)) -def draw_dot(surface: pygame.Surface, cx: int, y: int, - radius: int = 9) -> None: +def draw_dot(surface: pygame.Surface, cx: int, y: int, radius: int = 9) -> None: pygame.draw.circle(surface, WHITE, (cx, y), radius) # ── Font helper ─────────────────────────────────────────────────────────────── def _load_font(size: int, bold: bool = False) -> pygame.font.Font: - """Try a list of clean system fonts, fall back to pygame built-in.""" - for name in ('DejaVuSans', 'FreeSans', 'LiberationSans', - 'Helvetica', 'Arial', None): + for name in ('DejaVuSans', 'FreeSans', 'LiberationSans', 'Helvetica', 'Arial', None): try: f = pygame.font.SysFont(name, size, bold=bold) if f: @@ -195,59 +254,56 @@ def main() -> None: 'small': _load_font(13), } - # Layout anchors — all relative to screen size so they adapt to resolution - face_rx = int(min(W, H) * 0.085) # half-width at cheek level - face_ry = int(min(W, H) * 0.100) # half-height crown→chin - face_cx = W // 2 - face_cy = int(face_ry * 1.2) + 8 # nearly touches the top edge - status_y = int(H * 0.82) - dot_y = int(H * 0.895) + 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 + dot_y = int(H * 0.895) - # ── Load face wireframe PNG if present ──────────────────────────────────── - # Place any face wireframe PNG at assets/face_wire.png and it will be - # used automatically. Black background is composited away via BLEND_ADD - # so only the bright lines show. Falls back to parametric drawing. - _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() - img_src_w, img_src_h = raw.get_size() - # Scale to fit face slot while preserving aspect ratio - scale = min((face_rx * 2.2) / img_src_w, - (face_ry * 2.4) / img_src_h) - img_w = max(1, int(img_src_w * scale)) - img_h = max(1, int(img_src_h * scale)) - face_img = pygame.transform.smoothscale(raw, (img_w, img_h)) + clock_block_h = fonts['small'].get_height() + 4 + fonts['large'].get_height() + clock_y = face_cy - clock_block_h // 2 + weather_x = face_cx + face_rx + 30 - status_text = 'Initializing...' + # Resolve postcode and start weather thread + try: + lat, lon = _postcode_to_latlon(_args.location) + threading.Thread(target=_weather_worker, args=(lat, lon), daemon=True).start() + except Exception: + pass - clock = pygame.time.Clock() + _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: - if event.key in (pygame.K_ESCAPE, pygame.K_q): - pygame.quit() - return + pygame.quit(); return + if event.type == pygame.KEYDOWN and event.key in (pygame.K_ESCAPE, pygame.K_q): + pygame.quit(); return screen.fill(BLACK) + if face_img: - screen.blit(face_img, - face_img.get_rect(center=(face_cx, face_cy)), + 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_block_h = fonts['small'].get_height() + 4 + fonts['large'].get_height() - draw_clock(screen, fonts, 20, face_cy - clock_block_h // 2) - draw_status(screen, fonts, status_text, W // 2, status_y) + + draw_clock(screen, fonts, 20, clock_y) + draw_weather(screen, fonts, weather_x, clock_y) draw_dot(screen, W // 2, dot_y) pygame.display.flip() - clock.tick(10) # 10 fps is plenty for a status screen + pg_clock.tick(10) if __name__ == '__main__':