Compare commits
16 Commits
2e5ad58978
...
pi/general
| Author | SHA1 | Date | |
|---|---|---|---|
| 436e55a0c5 | |||
|
|
776aee18fc | ||
|
|
41e0a6b81d | ||
|
|
e4da3687b4 | ||
|
|
05cd085b89 | ||
|
|
d0d0b4dc39 | ||
|
|
25420d57b3 | ||
|
|
18984c29a3 | ||
|
|
7555efcba9 | ||
| 379f8e105b | |||
| 3b4d61c56d | |||
| 5ae0c64ba9 | |||
|
|
7f644652bb | ||
| 5c16e6deb7 | |||
|
|
706c7ac21b | ||
| b33c658885 |
@@ -11,7 +11,12 @@
|
||||
"Bash(python -m py_compile:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:docs.waveshare.com)",
|
||||
"WebFetch(domain:www.waveshare.com)"
|
||||
"WebFetch(domain:www.waveshare.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(npm view:*)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(docker ps:*)",
|
||||
"Bash(python3:*)"
|
||||
]
|
||||
},
|
||||
"outputStyle": "iseri",
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ managed_components/
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
dependencies.lock
|
||||
|
||||
# vscode local settings
|
||||
.vscode/
|
||||
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"idf.currentSetup": "J:\\esp\\.espressif\\v5.5.2\\esp-idf",
|
||||
"idf.flashType": "UART",
|
||||
"idf.portWin": "COM7",
|
||||
"idf.openOcdConfigs": [
|
||||
"interface/ftdi/esp_ftdi.cfg",
|
||||
"target/esp32s3.cfg"
|
||||
],
|
||||
"idf.customExtraVars": {
|
||||
"IDF_TARGET": "esp32s3"
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ stats_server.py --WS/JSON--> ws_client --> dashboard_ui (LVGL)
|
||||
|
||||
The Pi runs a WebSocket server that pushes system stats (CPU, memory, disk, temperature, network, services) as JSON every 2 seconds. The ESP32 parses the JSON and updates LVGL widgets. A data staleness watchdog forces reconnection if the server goes silent.
|
||||
|
||||
The display uses a two-column layout: left half shows Pi stats (CPU/RAM/DISK bars, CPU temp) and a services table; right half shows a large HH:MM:SS clock (montserrat_36), date with day-of-week, and local sensor readings (room temp, humidity). The services table auto-scrolls when more than 4 services are present. The clock updates every second from the on-board RTC, which syncs from the Pi's time when drift exceeds 60 seconds.
|
||||
The display uses a two-column layout: left half shows Pi stats (CPU/RAM/DISK bars, CPU temp) and a services table; right half shows a large HH:MM:SS clock (montserrat_36), date with day-of-week, and local sensor readings (room temp, humidity). The services table auto-scrolls when services exceed the visible area; row height and visible row count are measured from LVGL at runtime, so the scroll loop adapts automatically to font, padding, or border changes. The clock updates every second from the on-board RTC, which syncs from the Pi's time when drift exceeds 60 seconds.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ static uint8_t s_img_buf[STATUS_IMG_BYTES];
|
||||
static lv_img_dsc_t s_img_dsc;
|
||||
static volatile bool s_img_pending = false; /* expecting binary frame with image data */
|
||||
static volatile bool s_img_updated = false; /* new image ready for UI consumption */
|
||||
static volatile bool s_need_request_image = false; /* deferred image request on connect */
|
||||
static TaskHandle_t s_img_notify_task = NULL; /* task to wake on new image */
|
||||
|
||||
/* Forward declarations */
|
||||
@@ -157,11 +158,17 @@ static void ws_event_handler(void *arg, esp_event_base_t event_base,
|
||||
case WEBSOCKET_EVENT_CONNECTED:
|
||||
ESP_LOGI(TAG, "Audio WS connected");
|
||||
s_state = AUDIO_CONNECTED;
|
||||
s_img_pending = false;
|
||||
s_need_request_image = true;
|
||||
if (s_img_notify_task) {
|
||||
xTaskNotifyGive(s_img_notify_task);
|
||||
}
|
||||
break;
|
||||
|
||||
case WEBSOCKET_EVENT_DISCONNECTED:
|
||||
ESP_LOGW(TAG, "Audio WS disconnected");
|
||||
s_playing = false;
|
||||
s_img_pending = false;
|
||||
flush_queue();
|
||||
s_state = AUDIO_IDLE;
|
||||
break;
|
||||
@@ -275,9 +282,25 @@ const lv_img_dsc_t *audio_client_get_status_image(bool *updated)
|
||||
{
|
||||
if (updated) {
|
||||
*updated = s_img_updated;
|
||||
if (s_img_updated) {
|
||||
s_img_updated = false;
|
||||
}
|
||||
}
|
||||
return &s_img_dsc;
|
||||
}
|
||||
|
||||
void audio_client_ack_status_image(void)
|
||||
{
|
||||
s_img_updated = false;
|
||||
}
|
||||
|
||||
bool audio_client_send_pending_request(void)
|
||||
{
|
||||
if (!s_need_request_image || !s_client) return false;
|
||||
s_need_request_image = false;
|
||||
static const char REQUEST_IMG_JSON[] = "{\"type\":\"request_image\"}";
|
||||
int ret = esp_websocket_client_send_text(s_client, REQUEST_IMG_JSON, strlen(REQUEST_IMG_JSON), pdMS_TO_TICKS(1000));
|
||||
if (ret < 0) {
|
||||
ESP_LOGE(TAG, "Failed to send image request: %d", ret);
|
||||
return false;
|
||||
}
|
||||
ESP_LOGI(TAG, "Sent image request to server");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -40,11 +40,24 @@ void audio_client_set_image_notify_task(TaskHandle_t task);
|
||||
|
||||
/**
|
||||
* Get the latest status image descriptor.
|
||||
* @param updated Set to true if a new image arrived since last call, then reset.
|
||||
* @param updated Set to true if a new image arrived since last call.
|
||||
* @return Pointer to the static image descriptor (always valid).
|
||||
*/
|
||||
const lv_img_dsc_t *audio_client_get_status_image(bool *updated);
|
||||
|
||||
/**
|
||||
* Acknowledge that the status image was successfully rendered.
|
||||
* Clears the updated flag so subsequent get_status_image calls return false.
|
||||
*/
|
||||
void audio_client_ack_status_image(void);
|
||||
|
||||
/**
|
||||
* Send any pending image request to the server.
|
||||
* Call from a task context (not from an event handler).
|
||||
* @return true if a request was sent, false if none pending or send failed.
|
||||
*/
|
||||
bool audio_client_send_pending_request(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -39,6 +39,8 @@ static lv_obj_t *lbl_cpu_temp;
|
||||
/* Services table */
|
||||
static lv_obj_t *tbl_services;
|
||||
static int s_service_count;
|
||||
static int s_dup_rows; /* duplicate rows appended for looping */
|
||||
static lv_coord_t s_row_h; /* measured row height in px */
|
||||
|
||||
/* Local sensors (bottom bar) */
|
||||
static lv_obj_t *lbl_local;
|
||||
@@ -116,21 +118,54 @@ static lv_obj_t *create_label(lv_obj_t *parent, int x, int y, const lv_font_t *f
|
||||
static void scroll_timer_cb(lv_timer_t *timer)
|
||||
{
|
||||
(void)timer;
|
||||
if (s_service_count <= 4) {
|
||||
if (s_dup_rows <= 0 || s_row_h <= 0) {
|
||||
/* Too few services or not yet measured — no scrolling */
|
||||
lv_obj_scroll_to_y(tbl_services, 0, LV_ANIM_OFF);
|
||||
return;
|
||||
}
|
||||
|
||||
lv_coord_t cur_y = lv_obj_get_scroll_y(tbl_services);
|
||||
/* Each row is ~16px (12px font + 2px pad top + 2px pad bot) */
|
||||
lv_coord_t row_h = 16;
|
||||
lv_coord_t max_scroll = (s_service_count - 4) * row_h;
|
||||
lv_coord_t wrap_y = s_service_count * s_row_h;
|
||||
|
||||
if (cur_y >= max_scroll) {
|
||||
lv_obj_scroll_to_y(tbl_services, 0, LV_ANIM_ON);
|
||||
} else {
|
||||
lv_obj_scroll_to_y(tbl_services, cur_y + row_h, LV_ANIM_ON);
|
||||
if (cur_y >= wrap_y) {
|
||||
lv_obj_scroll_to_y(tbl_services, 0, LV_ANIM_OFF);
|
||||
cur_y = 0;
|
||||
}
|
||||
|
||||
// lv_obj_scroll_to_y(tbl_services, cur_y + s_row_h, LV_ANIM_OFF);
|
||||
const int delta_y = s_row_h / 8;
|
||||
lv_obj_scroll_to_y(tbl_services, cur_y + delta_y, LV_ANIM_ON);
|
||||
}
|
||||
|
||||
/* Measure row height, compute visible rows, append duplicates for seamless loop */
|
||||
static void fill_duplicate_rows(int count)
|
||||
{
|
||||
s_dup_rows = 0;
|
||||
s_row_h = 0;
|
||||
if (count <= 0) return;
|
||||
|
||||
/* Trim table to exactly count rows so measurement is clean */
|
||||
lv_table_set_row_cnt(tbl_services, count);
|
||||
lv_obj_update_layout(tbl_services);
|
||||
|
||||
lv_coord_t content_h = lv_obj_get_self_height(tbl_services);
|
||||
lv_coord_t row_h = content_h / count;
|
||||
if (row_h <= 0) return;
|
||||
|
||||
lv_coord_t visible_h = lv_obj_get_content_height(tbl_services);
|
||||
int visible_rows = (visible_h + row_h - 1) / row_h; /* ceil */
|
||||
|
||||
if (count <= visible_rows) return; /* everything fits — no scrolling */
|
||||
|
||||
for (int i = 0; i < visible_rows; i++) {
|
||||
lv_table_set_cell_value(tbl_services, count + i, 0,
|
||||
lv_table_get_cell_value(tbl_services, i, 0));
|
||||
lv_table_set_cell_value(tbl_services, count + i, 1,
|
||||
lv_table_get_cell_value(tbl_services, i, 1));
|
||||
}
|
||||
|
||||
s_dup_rows = visible_rows;
|
||||
s_row_h = row_h;
|
||||
}
|
||||
|
||||
/* ---------- Create UI sections ---------- */
|
||||
@@ -180,7 +215,7 @@ static void create_top_bar(lv_obj_t *parent)
|
||||
/* Battery positive terminal nub */
|
||||
lv_obj_t *batt_nub = lv_obj_create(bar_cont);
|
||||
lv_obj_set_size(batt_nub, 2, 4);
|
||||
lv_obj_align(batt_nub, LV_ALIGN_RIGHT_MID, -37, 0);
|
||||
lv_obj_align(batt_nub, LV_ALIGN_RIGHT_MID, -38, 0);
|
||||
lv_obj_set_style_bg_color(batt_nub, lv_color_white(), 0);
|
||||
lv_obj_set_style_bg_opa(batt_nub, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(batt_nub, 0, 0);
|
||||
@@ -221,18 +256,18 @@ static void create_time_bar(lv_obj_t *parent)
|
||||
lbl_date = lv_label_create(bar_cont);
|
||||
lv_obj_set_style_text_font(lbl_date, &InziuIosevka_Slab_CC_20px, 0);
|
||||
lv_obj_set_style_text_color(lbl_date, lv_color_black(), 0);
|
||||
lv_obj_align(lbl_date, LV_ALIGN_RIGHT_MID, -10, 0);
|
||||
lv_obj_align(lbl_date, LV_ALIGN_RIGHT_MID, -20, 0);
|
||||
lv_label_set_text(lbl_date, "----/--/-- ---");
|
||||
}
|
||||
|
||||
static void create_main_section(lv_obj_t *parent)
|
||||
{
|
||||
/* === Left column: Services + Pi Vitals === */
|
||||
create_label(parent, 4, MAIN_Y + 2, &InziuIosevka_Slab_CC_12px, "SERVICES");
|
||||
create_label(parent, 4, MAIN_Y + 1, &InziuIosevka_Slab_CC_12px, "SERVICES");
|
||||
|
||||
tbl_services = lv_table_create(parent);
|
||||
lv_obj_set_pos(tbl_services, 4, MAIN_Y + 16);
|
||||
lv_obj_set_size(tbl_services, 190, 68);
|
||||
lv_obj_set_size(tbl_services, 190, 82);
|
||||
lv_table_set_col_cnt(tbl_services, 2);
|
||||
lv_table_set_col_width(tbl_services, 0, 110);
|
||||
lv_table_set_col_width(tbl_services, 1, 65);
|
||||
@@ -248,8 +283,8 @@ static void create_main_section(lv_obj_t *parent)
|
||||
lv_table_set_cell_value(tbl_services, i, 1, "---");
|
||||
}
|
||||
|
||||
/* Auto-scroll timer: 3 second period */
|
||||
s_scroll_timer = lv_timer_create(scroll_timer_cb, 3000, NULL);
|
||||
/* Timer for each shift of 1/8 line's height */
|
||||
s_scroll_timer = lv_timer_create(scroll_timer_cb, 120, NULL);
|
||||
|
||||
/* === Left column: Pi Vitals (below services) === */
|
||||
int rx = 0;
|
||||
@@ -258,12 +293,12 @@ static void create_main_section(lv_obj_t *parent)
|
||||
int bar_w = 82;
|
||||
int bar_h = 12;
|
||||
int val_x = rx + 4 + lbl_w + bar_w + 4; /* value label after bar */
|
||||
int temp_x = rx + 160; /* TEMP column, right of value labels */
|
||||
int temp_x = rx + 155; /* TEMP column, right of value labels */
|
||||
|
||||
/* Pi Vitals header — Y=162 */
|
||||
create_label(parent, rx + 4, 162, &InziuIosevka_Slab_CC_12px, "PI VITALS");
|
||||
/* Pi Vitals header */
|
||||
create_label(parent, rx + 4, 175, &InziuIosevka_Slab_CC_12px, "PI VITALS");
|
||||
|
||||
int ry = 176;
|
||||
int ry = 192;
|
||||
|
||||
/* CPU [========] 12% TEMP */
|
||||
create_label(parent, rx + 4, ry, &InziuIosevka_Slab_CC_12px, "CPU");
|
||||
@@ -290,7 +325,7 @@ static void create_main_section(lv_obj_t *parent)
|
||||
|
||||
/* === Right column: Status image (200x200) === */
|
||||
img_status = lv_img_create(parent);
|
||||
lv_obj_set_pos(img_status, 200, MAIN_Y + 2);
|
||||
lv_obj_set_pos(img_status, 200, MAIN_Y + 1);
|
||||
lv_obj_set_size(img_status, 200, 200);
|
||||
lv_obj_set_style_bg_color(img_status, lv_color_white(), 0);
|
||||
lv_obj_set_style_bg_opa(img_status, LV_OPA_COVER, 0);
|
||||
@@ -303,7 +338,7 @@ static void create_bottom_bar(lv_obj_t *parent)
|
||||
lv_obj_t *bot_cont = lv_obj_create(parent);
|
||||
lv_obj_set_pos(bot_cont, 0, y0);
|
||||
lv_obj_set_size(bot_cont, SCREEN_W, BOT_H);
|
||||
lv_obj_set_style_bg_color(bot_cont, lv_color_white(), 0);
|
||||
lv_obj_set_style_bg_color(bot_cont, lv_color_black(), 0);
|
||||
lv_obj_set_style_bg_opa(bot_cont, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_color(bot_cont, lv_color_black(), 0);
|
||||
lv_obj_set_style_border_width(bot_cont, 1, 0);
|
||||
@@ -314,14 +349,14 @@ static void create_bottom_bar(lv_obj_t *parent)
|
||||
|
||||
lbl_net = lv_label_create(bot_cont);
|
||||
lv_obj_set_style_text_font(lbl_net, &InziuIosevka_Slab_CC_12px, 0);
|
||||
lv_obj_set_style_text_color(lbl_net, lv_color_black(), 0);
|
||||
lv_obj_set_style_text_color(lbl_net, lv_color_white(), 0);
|
||||
lv_obj_align(lbl_net, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
lv_label_set_text(lbl_net, "NETWORK RX: ---- kbps TX: ---- kbps");
|
||||
lv_label_set_text(lbl_net, "NETWORK DOWN: ---- kBps / UP: ---- kBps");
|
||||
|
||||
/* Local sensor readings — right-aligned */
|
||||
lbl_local = lv_label_create(bot_cont);
|
||||
lv_obj_set_style_text_font(lbl_local, &InziuIosevka_Slab_CC_12px, 0);
|
||||
lv_obj_set_style_text_color(lbl_local, lv_color_black(), 0);
|
||||
lv_obj_set_style_text_color(lbl_local, lv_color_white(), 0);
|
||||
lv_obj_align(lbl_local, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_label_set_text(lbl_local, "T: --.- H: --%");
|
||||
}
|
||||
@@ -369,15 +404,17 @@ void dashboard_ui_update_stats(const pi_stats_t *stats)
|
||||
/* Services table */
|
||||
s_service_count = stats->service_count;
|
||||
for (int i = 0; i < stats->service_count && i < WS_MAX_SERVICES; i++) {
|
||||
const char *tag;
|
||||
switch (stats->services[i].status) {
|
||||
case SVC_RUNNING: tag = "[RUN]"; break;
|
||||
case SVC_WARNING: tag = "[WARN]"; break;
|
||||
default: tag = "[STOP]"; break;
|
||||
}
|
||||
lv_table_set_cell_value(tbl_services, i, 0, stats->services[i].name);
|
||||
lv_table_set_cell_value(tbl_services, i, 1,
|
||||
stats->services[i].running ? "[RUN]" : "[STOP]");
|
||||
}
|
||||
/* Clear unused rows */
|
||||
for (int i = stats->service_count; i < WS_MAX_SERVICES; i++) {
|
||||
lv_table_set_cell_value(tbl_services, i, 0, "");
|
||||
lv_table_set_cell_value(tbl_services, i, 1, "");
|
||||
lv_table_set_cell_value(tbl_services, i, 1, tag);
|
||||
}
|
||||
/* Measure row height, compute visible rows, append duplicates */
|
||||
fill_duplicate_rows(stats->service_count);
|
||||
|
||||
/* Uptime */
|
||||
snprintf(buf, sizeof(buf), "Uptime: %.0fh", stats->uptime_hrs);
|
||||
@@ -385,7 +422,7 @@ void dashboard_ui_update_stats(const pi_stats_t *stats)
|
||||
|
||||
/* Network */
|
||||
char net_buf[64];
|
||||
snprintf(net_buf, sizeof(net_buf), "NETWORK RX: %.0f kbps TX: %.0f kbps",
|
||||
snprintf(net_buf, sizeof(net_buf), "NETWORK DOWN: %.0f kBps / UP: %.0f kBps",
|
||||
stats->net_rx_kbps, stats->net_tx_kbps);
|
||||
lv_label_set_text(lbl_net, net_buf);
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ static void ws_data_cb(const pi_stats_t *stats)
|
||||
}
|
||||
|
||||
for (int i = 0; i < stats->service_count; i++) {
|
||||
if (!stats->services[i].running) {
|
||||
if (stats->services[i].status != SVC_RUNNING) {
|
||||
alert_trigger(ALERT_SERVICE_DOWN);
|
||||
break;
|
||||
}
|
||||
@@ -194,12 +194,16 @@ static void sensor_task(void *arg)
|
||||
*/
|
||||
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1000));
|
||||
|
||||
/* Send deferred image request if connect just happened */
|
||||
audio_client_send_pending_request();
|
||||
|
||||
/* Check for status image updates immediately */
|
||||
bool img_updated = false;
|
||||
const lv_img_dsc_t *img = audio_client_get_status_image(&img_updated);
|
||||
if (img_updated && Lvgl_lock(100)) {
|
||||
dashboard_ui_update_status_image(img);
|
||||
Lvgl_unlock();
|
||||
audio_client_ack_status_image();
|
||||
}
|
||||
|
||||
/* Sensor + clock updates at ~1s cadence (skip if woken early) */
|
||||
@@ -245,10 +249,10 @@ static void sensor_task(void *arg)
|
||||
static void button_task(void *arg)
|
||||
{
|
||||
for (;;) {
|
||||
/* Wait for GP18 button event (single click = bit 0) */
|
||||
/* Wait for GP18 button event (single click = bit 0, long press = bit 2) */
|
||||
EventBits_t bits = xEventGroupWaitBits(
|
||||
GP18ButtonGroups,
|
||||
set_bit_button(0), /* single press bit */
|
||||
set_bit_button(0) | set_bit_button(2),
|
||||
pdTRUE, /* clear on exit */
|
||||
pdFALSE, /* any bit */
|
||||
pdMS_TO_TICKS(500)
|
||||
@@ -257,7 +261,12 @@ static void button_task(void *arg)
|
||||
if (bits & set_bit_button(0)) {
|
||||
bool muted = !alert_is_muted();
|
||||
alert_mute(muted);
|
||||
ESP_LOGI(TAG, "GP18 pressed: alerts %s", muted ? "muted" : "unmuted");
|
||||
ESP_LOGI(TAG, "GP18 single press: alerts %s", muted ? "muted" : "unmuted");
|
||||
}
|
||||
|
||||
if (bits & set_bit_button(2)) {
|
||||
ESP_LOGI(TAG, "GP18 long press: forcing WS reconnect");
|
||||
ws_client_reconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,13 @@ static void parse_stats_json(const char *data, int len)
|
||||
s_stats.services[i].name[WS_SERVICE_NAME_LEN - 1] = '\0';
|
||||
}
|
||||
if (status && status->valuestring) {
|
||||
s_stats.services[i].running = (strcmp(status->valuestring, "running") == 0);
|
||||
if (strcmp(status->valuestring, "running") == 0) {
|
||||
s_stats.services[i].status = SVC_RUNNING;
|
||||
} else if (strcmp(status->valuestring, "warning") == 0) {
|
||||
s_stats.services[i].status = SVC_WARNING;
|
||||
} else {
|
||||
s_stats.services[i].status = SVC_STOPPED;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +210,14 @@ void ws_client_stop(void)
|
||||
}
|
||||
}
|
||||
|
||||
void ws_client_reconnect(void)
|
||||
{
|
||||
if (!s_client) return;
|
||||
ESP_LOGI(TAG, "Manual WS reconnect triggered");
|
||||
esp_websocket_client_close(s_client, pdMS_TO_TICKS(2000));
|
||||
s_last_data_tick = xTaskGetTickCount();
|
||||
}
|
||||
|
||||
ws_state_t ws_client_get_state(void)
|
||||
{
|
||||
return s_state;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WS_MAX_SERVICES 8
|
||||
#define WS_MAX_SERVICES 20
|
||||
#define WS_SERVICE_NAME_LEN 16
|
||||
|
||||
typedef enum {
|
||||
@@ -18,9 +18,15 @@ typedef enum {
|
||||
WS_STATE_ERROR,
|
||||
} ws_state_t;
|
||||
|
||||
typedef enum {
|
||||
SVC_STOPPED = 0,
|
||||
SVC_WARNING,
|
||||
SVC_RUNNING,
|
||||
} ws_svc_status_t;
|
||||
|
||||
typedef struct {
|
||||
char name[WS_SERVICE_NAME_LEN];
|
||||
bool running;
|
||||
ws_svc_status_t status;
|
||||
} ws_service_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -53,6 +59,7 @@ typedef void (*ws_state_callback_t)(ws_state_t state);
|
||||
void ws_client_init(const char *uri);
|
||||
void ws_client_start(void);
|
||||
void ws_client_stop(void);
|
||||
void ws_client_reconnect(void);
|
||||
ws_state_t ws_client_get_state(void);
|
||||
void ws_client_get_stats(pi_stats_t *out);
|
||||
void ws_client_set_data_callback(ws_data_callback_t cb);
|
||||
|
||||
4
pi/.gitignore
vendored
4
pi/.gitignore
vendored
@@ -3,3 +3,7 @@
|
||||
__pycache__/
|
||||
*.pyo
|
||||
*.pyc
|
||||
|
||||
# configs
|
||||
config/
|
||||
!config/alarms.sample.json
|
||||
@@ -17,6 +17,7 @@ Protocol:
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -68,17 +69,17 @@ async def handler(ws):
|
||||
|
||||
configs = load_config(_config_path)
|
||||
img_idle = load_status_image(IMG_DIR / "idle.png")
|
||||
current_img = img_idle
|
||||
|
||||
try:
|
||||
await send_status_image(ws, img_idle)
|
||||
alarms = [_prepare_alarm(entry) for entry in configs] if configs else []
|
||||
|
||||
if not configs:
|
||||
async def alarm_ticker():
|
||||
nonlocal current_img
|
||||
if not alarms:
|
||||
log.info("No alarms configured — idling forever")
|
||||
await asyncio.Future()
|
||||
return
|
||||
|
||||
alarms = [_prepare_alarm(entry) for entry in configs]
|
||||
|
||||
while True:
|
||||
for alarm in alarms:
|
||||
if should_fire(alarm["config"]):
|
||||
@@ -88,13 +89,30 @@ async def handler(ws):
|
||||
alarm["last_fired"] = current_minute
|
||||
log.info("Alarm firing: %s at %s",
|
||||
alarm["config"]["alarm_time"], current_minute)
|
||||
await send_status_image(ws, alarm["img"])
|
||||
current_img = alarm["img"]
|
||||
await send_status_image(ws, current_img)
|
||||
await stream_alarm(ws, alarm["pcm"], alarm["sr"],
|
||||
alarm["ch"], alarm["bits"])
|
||||
await send_status_image(ws, img_idle)
|
||||
# let the image persist a bit more
|
||||
await asyncio.sleep(1)
|
||||
current_img = img_idle
|
||||
await send_status_image(ws, current_img)
|
||||
|
||||
await asyncio.sleep(TICK_INTERVAL)
|
||||
|
||||
async def receiver():
|
||||
async for msg in ws:
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if data.get("type") == "request_image":
|
||||
log.info("Client requested image — sending current (%d bytes)",
|
||||
len(current_img))
|
||||
await send_status_image(ws, current_img)
|
||||
|
||||
try:
|
||||
await asyncio.gather(alarm_ticker(), receiver())
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
log.info("Client disconnected: %s:%d", remote[0], remote[1])
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ same 2s push interval. Services remain mocked until systemd integration is added
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -60,17 +60,52 @@ def _get_net_throughput() -> tuple[float, float]:
|
||||
|
||||
return rx_kbps, tx_kbps
|
||||
|
||||
# only services that matter
|
||||
SERVICES_ALIASES = {
|
||||
"gitea": "gitea",
|
||||
"samba": "samba",
|
||||
"pihole": "pihole",
|
||||
"qbittorrent": "qbittorrent",
|
||||
"frpc-primary": "frpc (ny)",
|
||||
"pinepods": "pinepods",
|
||||
"frpc-ssh": "frpc (ssh)",
|
||||
"jellyfin": "jellyfin",
|
||||
}
|
||||
def _get_docker_services() -> list[dict]:
|
||||
"""Query Docker for real container statuses with ternary status model."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.Status}}"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return []
|
||||
|
||||
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"},
|
||||
{"name": "ph1", "status": "running"},
|
||||
{"name": "ph2", "status": "stopped"},
|
||||
]
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
services = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
parts = line.split("\t", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
name, raw_status = parts
|
||||
|
||||
if (name in SERVICES_ALIASES):
|
||||
if raw_status.startswith("Up"):
|
||||
if "unhealthy" in raw_status or "Restarting" in raw_status:
|
||||
status = "warning"
|
||||
else:
|
||||
status = "running"
|
||||
else:
|
||||
status = "stopped"
|
||||
services.append({"name": SERVICES_ALIASES[name], "status": status})
|
||||
|
||||
# Sort: warnings first, then stopped, then running (problems float to top)
|
||||
order = {"warning": 0, "stopped": 1, "running": 2}
|
||||
services.sort(key=lambda s: order.get(s["status"], 3))
|
||||
|
||||
return services
|
||||
|
||||
|
||||
def _local_time_fields() -> dict:
|
||||
@@ -100,7 +135,7 @@ def generate_stats() -> dict:
|
||||
"uptime_hrs": round((time.time() - psutil.boot_time()) / 3600, 1),
|
||||
"net_rx_kbps": rx_kbps / 8,
|
||||
"net_tx_kbps": tx_kbps / 8, # kByte/s for humans
|
||||
"services": _mock_services(),
|
||||
"services": _get_docker_services(),
|
||||
"timestamp": int(time.time()),
|
||||
"local_time": _local_time_fields(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user