pi server realised

This commit is contained in:
2026-02-15 12:57:05 +09:00
parent 89f75c23ef
commit 9ca0227214
7 changed files with 197 additions and 1 deletions

View File

@@ -5,5 +5,6 @@
]
},
"outputStyle": "iseri",
"prefersReducedMotion": false
"spinnerTipsEnabled": false,
"prefersReducedMotion": true
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

5
pi/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# python artifacts
*/__pycache__
__pycache__/
*.pyo
*.pyc

View File

@@ -1 +1,2 @@
websockets>=12.0
psutil>=5.9.0

126
pi/stats_server.py Normal file
View File

@@ -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())