diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bced40d..21f737e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,5 +5,6 @@ ] }, "outputStyle": "iseri", - "prefersReducedMotion": false + "spinnerTipsEnabled": false, + "prefersReducedMotion": true } diff --git a/components/user_app/user_app.cpp b/components/user_app/user_app.cpp index 93926c0..3e6f3b7 100644 --- a/components/user_app/user_app.cpp +++ b/components/user_app/user_app.cpp @@ -89,6 +89,33 @@ void UserApp_TaskInit(void) /* ---------- WebSocket callbacks ---------- */ +static void rtc_sync_if_needed(const pi_stats_t *stats) +{ + if (!stats->time_valid) return; + + rtcTimeStruct_t rtc = {}; + Rtc_GetTime(&rtc); + + /* Convert both to seconds-since-midnight for comparison */ + int pi_secs = stats->time_hour * 3600 + stats->time_minute * 60 + stats->time_second; + int rtc_secs = rtc.hour * 3600 + rtc.minute * 60 + rtc.second; + int delta = pi_secs - rtc_secs; + if (delta < 0) delta = -delta; + + /* Also check date mismatch as an immediate trigger */ + bool date_mismatch = (rtc.year != stats->time_year || + rtc.month != stats->time_month || + rtc.day != stats->time_day); + + if (date_mismatch || delta > 60) { + Rtc_SetTime(stats->time_year, stats->time_month, stats->time_day, + stats->time_hour, stats->time_minute, stats->time_second); + ESP_LOGI(TAG, "RTC synced from Pi: %04d-%02d-%02d %02d:%02d:%02d (drift: %ds)", + stats->time_year, stats->time_month, stats->time_day, + stats->time_hour, stats->time_minute, stats->time_second, delta); + } +} + static void ws_data_cb(const pi_stats_t *stats) { /* Check alert conditions */ @@ -103,6 +130,9 @@ static void ws_data_cb(const pi_stats_t *stats) } } + /* Sync RTC if Pi time drifts from board clock */ + rtc_sync_if_needed(stats); + /* Update UI under LVGL lock */ if (Lvgl_lock(100)) { dashboard_ui_update_stats(stats); diff --git a/components/ws_client/ws_client.c b/components/ws_client/ws_client.c index 0723293..242a591 100644 --- a/components/ws_client/ws_client.c +++ b/components/ws_client/ws_client.c @@ -97,6 +97,30 @@ static void parse_stats_json(const char *data, int len) } } + /* Parse local_time object for RTC sync */ + cJSON *lt = cJSON_GetObjectItem(root, "local_time"); + if (cJSON_IsObject(lt)) { + cJSON *y = cJSON_GetObjectItem(lt, "y"); + cJSON *mo = cJSON_GetObjectItem(lt, "mo"); + cJSON *d = cJSON_GetObjectItem(lt, "d"); + cJSON *h = cJSON_GetObjectItem(lt, "h"); + cJSON *m = cJSON_GetObjectItem(lt, "m"); + cJSON *s = cJSON_GetObjectItem(lt, "s"); + if (y && mo && d && h && m && s) { + s_stats.time_year = (uint16_t)y->valuedouble; + s_stats.time_month = (uint8_t)mo->valuedouble; + s_stats.time_day = (uint8_t)d->valuedouble; + s_stats.time_hour = (uint8_t)h->valuedouble; + s_stats.time_minute = (uint8_t)m->valuedouble; + s_stats.time_second = (uint8_t)s->valuedouble; + s_stats.time_valid = true; + } else { + s_stats.time_valid = false; + } + } else { + s_stats.time_valid = false; + } + s_stats.valid = true; xSemaphoreGive(s_stats_mutex); diff --git a/components/ws_client/ws_client.h b/components/ws_client/ws_client.h index 76271ec..e713dab 100644 --- a/components/ws_client/ws_client.h +++ b/components/ws_client/ws_client.h @@ -36,6 +36,15 @@ typedef struct { uint8_t service_count; uint32_t last_update; // timestamp from server bool valid; // set true after first successful parse + + /* Broken-down local time from Pi for RTC sync */ + uint16_t time_year; + uint8_t time_month; + uint8_t time_day; + uint8_t time_hour; + uint8_t time_minute; + uint8_t time_second; + bool time_valid; // true when local_time object was parsed } pi_stats_t; typedef void (*ws_data_callback_t)(const pi_stats_t *stats); diff --git a/pi/.gitignore b/pi/.gitignore new file mode 100644 index 0000000..27ffb99 --- /dev/null +++ b/pi/.gitignore @@ -0,0 +1,5 @@ +# python artifacts +*/__pycache__ +__pycache__/ +*.pyo +*.pyc diff --git a/pi/requirements.txt b/pi/requirements.txt index 31b5e2f..0a6cc9e 100644 --- a/pi/requirements.txt +++ b/pi/requirements.txt @@ -1 +1,2 @@ websockets>=12.0 +psutil>=5.9.0 diff --git a/pi/stats_server.py b/pi/stats_server.py new file mode 100644 index 0000000..cc7963f --- /dev/null +++ b/pi/stats_server.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""WebSocket server that sends real Pi system stats every 2 seconds. + +Drop-in replacement for mock_server.py. Same port (8765), same JSON schema, +same 2s push interval. Services remain mocked until systemd integration is added. +""" + +import asyncio +import json +import random +import time +from datetime import datetime +from pathlib import Path + +import psutil +import websockets + +# Prime the CPU percent counter (first call always returns 0.0) +psutil.cpu_percent(interval=None) + +# Network baseline for delta calculation +_prev_net = psutil.net_io_counters() +_prev_net_time = time.monotonic() + + +def _get_cpu_temp() -> float: + """Read CPU temperature with fallback for different Pi OS versions.""" + try: + temps = psutil.sensors_temperatures() + if "cpu_thermal" in temps and temps["cpu_thermal"]: + return round(temps["cpu_thermal"][0].current, 1) + except (AttributeError, KeyError): + pass + + # Fallback: read sysfs directly (value is in millidegrees) + thermal_path = Path("/sys/class/thermal/thermal_zone0/temp") + try: + millidegrees = int(thermal_path.read_text().strip()) + return round(millidegrees / 1000.0, 1) + except (FileNotFoundError, ValueError, PermissionError): + return 0.0 + + +def _get_net_throughput() -> tuple[float, float]: + """Calculate network rx/tx in kbps since last call.""" + global _prev_net, _prev_net_time + + now = time.monotonic() + current = psutil.net_io_counters() + elapsed = now - _prev_net_time + + if elapsed <= 0: + return 0.0, 0.0 + + rx_kbps = round((current.bytes_recv - _prev_net.bytes_recv) * 8 / (elapsed * 1000), 1) + tx_kbps = round((current.bytes_sent - _prev_net.bytes_sent) * 8 / (elapsed * 1000), 1) + + _prev_net = current + _prev_net_time = now + + return rx_kbps, tx_kbps + + +def _mock_services() -> list[dict]: + """Mocked service status — same logic as mock_server.py.""" + return [ + {"name": "docker", "status": random.choice(["running", "running", "running", "stopped"])}, + {"name": "pihole", "status": random.choice(["running", "running", "running", "stopped"])}, + {"name": "nginx", "status": random.choice(["running", "running", "stopped"])}, + {"name": "sshd", "status": "running"}, + ] + + +def _local_time_fields() -> dict: + """Current local time as broken-down fields for RTC sync.""" + now = datetime.now() + return { + "y": now.year, + "mo": now.month, + "d": now.day, + "h": now.hour, + "m": now.minute, + "s": now.second, + } + + +def generate_stats() -> dict: + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + rx_kbps, tx_kbps = _get_net_throughput() + + return { + "cpu_pct": psutil.cpu_percent(interval=None), + "mem_pct": round(mem.percent, 1), + "mem_used_mb": int(mem.used // (1024 * 1024)), + "disk_pct": round(disk.percent, 1), + "cpu_temp": _get_cpu_temp(), + "uptime_hrs": round((time.time() - psutil.boot_time()) / 3600, 1), + "net_rx_kbps": rx_kbps, + "net_tx_kbps": tx_kbps, + "services": _mock_services(), + "timestamp": int(time.time()), + "local_time": _local_time_fields(), + } + + +async def handler(websocket): + addr = websocket.remote_address + print(f"Client connected: {addr}") + try: + while True: + stats = generate_stats() + await websocket.send(json.dumps(stats)) + await asyncio.sleep(2) + except websockets.ConnectionClosed: + print(f"Client disconnected: {addr}") + + +async def main(): + print("Pi stats server starting on ws://0.0.0.0:8765") + async with websockets.serve(handler, "0.0.0.0", 8765): + await asyncio.Future() # run forever + + +if __name__ == "__main__": + asyncio.run(main())