pi server realised
This commit is contained in:
@@ -5,5 +5,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"outputStyle": "iseri",
|
"outputStyle": "iseri",
|
||||||
"prefersReducedMotion": false
|
"spinnerTipsEnabled": false,
|
||||||
|
"prefersReducedMotion": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,33 @@ void UserApp_TaskInit(void)
|
|||||||
|
|
||||||
/* ---------- WebSocket callbacks ---------- */
|
/* ---------- 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)
|
static void ws_data_cb(const pi_stats_t *stats)
|
||||||
{
|
{
|
||||||
/* Check alert conditions */
|
/* 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 */
|
/* Update UI under LVGL lock */
|
||||||
if (Lvgl_lock(100)) {
|
if (Lvgl_lock(100)) {
|
||||||
dashboard_ui_update_stats(stats);
|
dashboard_ui_update_stats(stats);
|
||||||
|
|||||||
@@ -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;
|
s_stats.valid = true;
|
||||||
xSemaphoreGive(s_stats_mutex);
|
xSemaphoreGive(s_stats_mutex);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ typedef struct {
|
|||||||
uint8_t service_count;
|
uint8_t service_count;
|
||||||
uint32_t last_update; // timestamp from server
|
uint32_t last_update; // timestamp from server
|
||||||
bool valid; // set true after first successful parse
|
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;
|
} pi_stats_t;
|
||||||
|
|
||||||
typedef void (*ws_data_callback_t)(const pi_stats_t *stats);
|
typedef void (*ws_data_callback_t)(const pi_stats_t *stats);
|
||||||
|
|||||||
5
pi/.gitignore
vendored
Normal file
5
pi/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# python artifacts
|
||||||
|
*/__pycache__
|
||||||
|
__pycache__/
|
||||||
|
*.pyo
|
||||||
|
*.pyc
|
||||||
@@ -1 +1,2 @@
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
|
psutil>=5.9.0
|
||||||
|
|||||||
126
pi/stats_server.py
Normal file
126
pi/stats_server.py
Normal 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())
|
||||||
Reference in New Issue
Block a user