Compare commits
24 Commits
1140de0fd4
...
bugfix/ws-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0d0b4dc39 | ||
|
|
25420d57b3 | ||
|
|
18984c29a3 | ||
|
|
7555efcba9 | ||
| 379f8e105b | |||
| 3b4d61c56d | |||
| 5ae0c64ba9 | |||
|
|
7f644652bb | ||
| 5c16e6deb7 | |||
|
|
706c7ac21b | ||
| b33c658885 | |||
|
|
2e5ad58978 | ||
|
|
8ffe8cdffb | ||
|
|
b0a5e05e4a | ||
|
|
4e2c714e1f | ||
|
|
7165f56464 | ||
|
|
a25e4b2cb3 | ||
|
|
76bf8c966d | ||
|
|
d63322d304 | ||
|
|
0b2f274719 | ||
|
|
22c32f3538 | ||
|
|
89c975bf17 | ||
|
|
e5cc124dd3 | ||
|
|
a0cec91845 |
@@ -7,7 +7,16 @@
|
||||
"Bash(do sed -i '/\\\\.static_bitmap = 0,/d' \"$f\")",
|
||||
"Bash(done)",
|
||||
"Bash(file:*)",
|
||||
"mcp__ide__getDiagnostics"
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(python -m py_compile:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:docs.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
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ static const char *TAG = "audio_client";
|
||||
#define WS_BUFFER_SIZE 8192
|
||||
|
||||
/* Status image constants */
|
||||
#define STATUS_IMG_W 120
|
||||
#define STATUS_IMG_H 120
|
||||
#define STATUS_IMG_BYTES (STATUS_IMG_W * STATUS_IMG_H / 8) /* 1800 */
|
||||
#define STATUS_IMG_W 200
|
||||
#define STATUS_IMG_H 200
|
||||
#define STATUS_IMG_BYTES (STATUS_IMG_W * STATUS_IMG_H / 8) /* 5000 */
|
||||
|
||||
static esp_websocket_client_handle_t s_client = NULL;
|
||||
static CodecPort *s_codec = NULL;
|
||||
@@ -35,6 +35,8 @@ 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 */
|
||||
static void playback_task(void *arg);
|
||||
@@ -121,6 +123,9 @@ static void handle_binary_frame(const uint8_t *data, int len)
|
||||
if (len == STATUS_IMG_BYTES) {
|
||||
memcpy(s_img_buf, data, STATUS_IMG_BYTES);
|
||||
s_img_updated = true;
|
||||
if (s_img_notify_task) {
|
||||
xTaskNotifyGive(s_img_notify_task);
|
||||
}
|
||||
ESP_LOGI(TAG, "Status image received (%d bytes)", len);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Status image size mismatch: got %d, expected %d", len, STATUS_IMG_BYTES);
|
||||
@@ -153,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;
|
||||
@@ -262,13 +273,34 @@ audio_state_t audio_client_get_state(void)
|
||||
return s_state;
|
||||
}
|
||||
|
||||
void audio_client_set_image_notify_task(TaskHandle_t task)
|
||||
{
|
||||
s_img_notify_task = task;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include "lvgl.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
@@ -30,13 +32,32 @@ void audio_client_stop(void);
|
||||
/** Get current audio client state. */
|
||||
audio_state_t audio_client_get_state(void);
|
||||
|
||||
/**
|
||||
* Register a task to be notified (via xTaskNotifyGive) when a new status
|
||||
* image arrives. Call before audio_client_start().
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
/* Top bar */
|
||||
static lv_obj_t *lbl_ip;
|
||||
static lv_obj_t *lbl_batt;
|
||||
static lv_obj_t *lbl_batt_v;
|
||||
static lv_obj_t *bar_batt_top;
|
||||
static lv_obj_t *lbl_ws;
|
||||
|
||||
@@ -38,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;
|
||||
@@ -115,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 ---------- */
|
||||
@@ -160,6 +196,13 @@ static void create_top_bar(lv_obj_t *parent)
|
||||
lv_obj_align(lbl_ws, LV_ALIGN_LEFT_MID, 140, 0);
|
||||
lv_label_set_text(lbl_ws, "WS:---");
|
||||
|
||||
/* Battery voltage — left of bar */
|
||||
lbl_batt_v = lv_label_create(bar_cont);
|
||||
lv_obj_set_style_text_color(lbl_batt_v, lv_color_white(), 0);
|
||||
lv_obj_set_style_text_font(lbl_batt_v, &InziuIosevka_Slab_CC_12px, 0);
|
||||
lv_obj_align(lbl_batt_v, LV_ALIGN_RIGHT_MID, -72, 0);
|
||||
lv_label_set_text(lbl_batt_v, "-.--V");
|
||||
|
||||
/* Battery bar (24x10) — right */
|
||||
bar_batt_top = lv_bar_create(bar_cont);
|
||||
lv_obj_set_size(bar_batt_top, 24, 10);
|
||||
@@ -169,6 +212,17 @@ static void create_top_bar(lv_obj_t *parent)
|
||||
lv_obj_add_style(bar_batt_top, &style_bar_batt_bg, LV_PART_MAIN);
|
||||
lv_obj_add_style(bar_batt_top, &style_bar_batt_ind, LV_PART_INDICATOR);
|
||||
|
||||
/* 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_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);
|
||||
lv_obj_set_style_radius(batt_nub, 0, 0);
|
||||
lv_obj_set_style_pad_all(batt_nub, 0, 0);
|
||||
lv_obj_clear_flag(batt_nub, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
/* Battery text — right of bar */
|
||||
lbl_batt = lv_label_create(bar_cont);
|
||||
lv_obj_set_style_text_color(lbl_batt, lv_color_white(), 0);
|
||||
@@ -202,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 === */
|
||||
create_label(parent, 4, MAIN_Y + 2, &InziuIosevka_Slab_CC_12px, "SERVICES");
|
||||
/* === Left column: Services + Pi Vitals === */
|
||||
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, 180);
|
||||
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);
|
||||
@@ -229,22 +283,22 @@ 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, 200, NULL);
|
||||
|
||||
/* === Right column: Pi Vitals + Local Sensors === */
|
||||
int rx = RIGHT_COL_X;
|
||||
/* === Left column: Pi Vitals (below services) === */
|
||||
int rx = 0;
|
||||
int row_h = 18; /* vertical spacing per stat row */
|
||||
int lbl_w = 36; /* width reserved for "CPU " etc */
|
||||
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 + 156; /* TEMP column, right of value labels */
|
||||
int temp_x = rx + 155; /* TEMP column, right of value labels */
|
||||
|
||||
/* Pi Vitals header */
|
||||
create_label(parent, rx + 4, MAIN_Y + 2, &InziuIosevka_Slab_CC_12px, "PI VITALS");
|
||||
create_label(parent, rx + 4, 175, &InziuIosevka_Slab_CC_12px, "PI VITALS");
|
||||
|
||||
int ry = MAIN_Y + 18;
|
||||
int ry = 192;
|
||||
|
||||
/* CPU [========] 12% TEMP */
|
||||
create_label(parent, rx + 4, ry, &InziuIosevka_Slab_CC_12px, "CPU");
|
||||
@@ -269,10 +323,10 @@ static void create_main_section(lv_obj_t *parent)
|
||||
ry += row_h;
|
||||
lbl_uptime = create_label(parent, rx + 4, ry, &InziuIosevka_Slab_CC_12px, "Uptime: --h");
|
||||
|
||||
/* === Status image (120x120, bottom-right above bot bar) === */
|
||||
/* === Right column: Status image (200x200) === */
|
||||
img_status = lv_img_create(parent);
|
||||
lv_obj_set_pos(img_status, 280, 156);
|
||||
lv_obj_set_size(img_status, 120, 120);
|
||||
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);
|
||||
}
|
||||
@@ -297,7 +351,7 @@ static void create_bottom_bar(lv_obj_t *parent)
|
||||
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_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);
|
||||
@@ -350,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);
|
||||
@@ -366,20 +422,24 @@ 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);
|
||||
}
|
||||
|
||||
void dashboard_ui_update_local(float temp, float humidity, uint8_t battery)
|
||||
void dashboard_ui_update_local(float temp, float humidity, uint8_t battery, bool charging, float voltage)
|
||||
{
|
||||
char buf[32];
|
||||
|
||||
snprintf(buf, sizeof(buf), "T: %.1f H: %.0f%%", temp, humidity);
|
||||
lv_label_set_text(lbl_local, buf);
|
||||
|
||||
/* Update top bar battery voltage */
|
||||
snprintf(buf, sizeof(buf), "%.2fV", voltage);
|
||||
lv_label_set_text(lbl_batt_v, buf);
|
||||
|
||||
/* Update top bar battery text + bar */
|
||||
snprintf(buf, sizeof(buf), "%d%%", battery);
|
||||
snprintf(buf, sizeof(buf), charging ? "%d%%C" : "%d%%", battery);
|
||||
lv_label_set_text(lbl_batt, buf);
|
||||
lv_bar_set_value(bar_batt_top, battery, LV_ANIM_OFF);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ void dashboard_ui_update_stats(const pi_stats_t *stats);
|
||||
/**
|
||||
* Update local sensor readings. LVGL lock must be held by caller.
|
||||
*/
|
||||
void dashboard_ui_update_local(float temp, float humidity, uint8_t battery);
|
||||
void dashboard_ui_update_local(float temp, float humidity, uint8_t battery, bool charging, float voltage);
|
||||
|
||||
/**
|
||||
* Update clock and date display. LVGL lock must be held by caller.
|
||||
|
||||
@@ -48,3 +48,7 @@ uint8_t Adc_GetBatteryLevel() {
|
||||
//ESP_LOGW("Battery","Voltage: %.3f V, Level: %.1f %%",vol,level);
|
||||
return (uint8_t)level;
|
||||
}
|
||||
|
||||
bool Adc_IsCharging() {
|
||||
return Adc_GetBatteryVoltage() > 4.10f;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
void Adc_PortInit();
|
||||
float Adc_GetBatteryVoltage();
|
||||
uint8_t Adc_GetBatteryLevel();
|
||||
bool Adc_IsCharging();
|
||||
@@ -43,7 +43,7 @@ static const int s_beep_count[ALERT_TYPE_COUNT] = {
|
||||
0, /* SERVICE_DOWN: triple beep */ /* DISABLED FOR TESTING */
|
||||
2, /* HIGH_TEMP: double beep */
|
||||
0, /* WS_DISCONNECT: single beep */ /* DISABLED FOR TESTING */
|
||||
1, /* CONNECT_OK: single short beep */
|
||||
0, /* CONNECT_OK: single short beep */ /* DISABLED FOR TESTING */
|
||||
};
|
||||
|
||||
static void generate_tone_buffer(void)
|
||||
|
||||
@@ -82,11 +82,13 @@ void UserApp_TaskInit(void)
|
||||
/* Start WebSocket client */
|
||||
ws_client_start();
|
||||
|
||||
/* Start audio streaming client */
|
||||
audio_client_start();
|
||||
|
||||
/* Sensor polling task - Core 1, 4KB stack */
|
||||
xTaskCreatePinnedToCore(sensor_task, "sensor", 4 * 1024, NULL, 3, NULL, 1);
|
||||
TaskHandle_t sensor_handle = NULL;
|
||||
xTaskCreatePinnedToCore(sensor_task, "sensor", 4 * 1024, NULL, 3, &sensor_handle, 1);
|
||||
|
||||
/* Start audio streaming client, notify sensor task on new images */
|
||||
audio_client_set_image_notify_task(sensor_handle);
|
||||
audio_client_start();
|
||||
|
||||
/* Button handling task - Core 1 */
|
||||
xTaskCreatePinnedToCore(button_task, "button", 2 * 1024, NULL, 2, NULL, 1);
|
||||
@@ -131,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;
|
||||
}
|
||||
@@ -182,9 +184,36 @@ static void sensor_task(void *arg)
|
||||
float temp = 0, humidity = 0;
|
||||
rtcTimeStruct_t rtc_time = {};
|
||||
int sensor_divider = 0;
|
||||
TickType_t last_sensor_tick = xTaskGetTickCount();
|
||||
|
||||
for (;;) {
|
||||
/* Read RTC every second */
|
||||
/*
|
||||
* Sleep until either:
|
||||
* - a new status image arrives (xTaskNotifyGive from audio_client), or
|
||||
* - 1 second elapses (clock refresh cadence)
|
||||
*/
|
||||
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) */
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
if ((now - last_sensor_tick) < pdMS_TO_TICKS(900)) {
|
||||
continue;
|
||||
}
|
||||
last_sensor_tick = now;
|
||||
|
||||
/* Read RTC */
|
||||
Rtc_GetTime(&rtc_time);
|
||||
|
||||
/* Read SHTC3 + battery every 5 seconds */
|
||||
@@ -194,32 +223,24 @@ static void sensor_task(void *arg)
|
||||
s_shtc3->Shtc3_ReadTempHumi(&temp, &humidity);
|
||||
s_shtc3->Shtc3_Sleep();
|
||||
|
||||
float batt_v = Adc_GetBatteryVoltage();
|
||||
uint8_t batt = Adc_GetBatteryLevel();
|
||||
bool charging = Adc_IsCharging();
|
||||
|
||||
if (Lvgl_lock(100)) {
|
||||
dashboard_ui_update_local(temp, humidity, batt);
|
||||
dashboard_ui_update_local(temp, humidity, batt, charging, batt_v);
|
||||
Lvgl_unlock();
|
||||
}
|
||||
}
|
||||
sensor_divider = (sensor_divider + 1) % 5;
|
||||
|
||||
/* Update clock every second */
|
||||
/* Update clock */
|
||||
if (Lvgl_lock(100)) {
|
||||
dashboard_ui_update_time(rtc_time.hour, rtc_time.minute, rtc_time.second,
|
||||
rtc_time.year, rtc_time.month, rtc_time.day,
|
||||
rtc_time.week);
|
||||
Lvgl_unlock();
|
||||
}
|
||||
|
||||
/* Poll for status image updates */
|
||||
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();
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,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)
|
||||
@@ -240,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);
|
||||
|
||||
39
pi/PLAN.md
39
pi/PLAN.md
@@ -1,40 +1,5 @@
|
||||
# Pi Servers -- Roadmap
|
||||
|
||||
## Alarm Configuration
|
||||
|
||||
Replace the current `randint(30, 60)` test loop in `contents_server.py` with real schedule-driven alarms.
|
||||
|
||||
### Config schema
|
||||
|
||||
File: `pi/alarm_config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"alarm_time": "0700",
|
||||
"alarm_days": ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
||||
"alarm_dates": ["07/20", "10/21"],
|
||||
"alarm_audio": "assets/alarm/alarm_test.wav",
|
||||
"alarm_image": "assets/img/on_alarm.png"
|
||||
}
|
||||
```
|
||||
|
||||
### Field definitions
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `alarm_time` | `string` | 4-digit HHMM (24-hour). Triggers on the 0th second of the minute, or as soon as Python detects it. |
|
||||
| `alarm_days` | `string[]` | (Optional) 3-letter day abbreviations: `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`, `Sun`. Alarm only fires on listed days. If not supplied, alarms every day. |
|
||||
| `alarm_dates` | `string[]` | (Optional) Strings of `MM/DD` format. If both `alarm_days` and `alarm_dates` are set, only `alarm_days` is effective. |
|
||||
| `alarm_audio` | `string` | Path to WAV file. Relative paths resolve from `pi/`. |
|
||||
| `alarm_image` | `string` | Path to status PNG shown during alarm. Relative paths resolve from `pi/`. If not supplied, default to `assets/img/on_alarm.png` |
|
||||
|
||||
### Behavior
|
||||
|
||||
- On startup, load and validate `alarm_config.json`
|
||||
- Each tick (~5s), check if current local time matches `alarm_time` and today's day name is in `alarm_days`, or if today's date is in `alarm_dates`.
|
||||
- Fire alarm once per matched minute (debounce so it doesn't re-trigger within the same minute)
|
||||
- After alarm completes, return to idle image and resume schedule checking
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Containerize the pi servers for easier deployment.
|
||||
@@ -48,7 +13,7 @@ Single service is simpler. Split services allow independent scaling and restarts
|
||||
|
||||
### Configuration
|
||||
|
||||
- Volume mount `assets/` and `alarm_config.json` so they're editable without rebuilding
|
||||
- Volume mount `assets/` and `config/alarms.json` so they're editable without rebuilding
|
||||
- Expose ports 8765 and 8766
|
||||
- Network mode `host` or a bridge with known IPs for ESP32 discovery
|
||||
- Restart policy: `unless-stopped`
|
||||
@@ -61,7 +26,7 @@ The `pi/` directory will become its own git repository.
|
||||
|
||||
1. Extract `pi/` into a standalone repo with its own `README.md`, `requirements.txt`, and CI
|
||||
2. Add it back to this project as a git submodule
|
||||
3. The interface contract between the two repos is the WebSocket protocol -- JSON schemas and binary frame formats documented in `docs/ALARM_PROTOCOL.md`
|
||||
3. The interface contract between the two repos is the WebSocket protocol -- JSON schemas and binary frame formats documented in `README.md`
|
||||
|
||||
### Benefits
|
||||
|
||||
|
||||
49
pi/README.md
49
pi/README.md
@@ -12,7 +12,10 @@ pi/
|
||||
mock_server.py # Drop-in replacement for stats_server with random data
|
||||
audio_handler.py # WAV loading, PCM chunking, alarm streaming
|
||||
image_handler.py # PNG to 1-bit monochrome conversion, alpha compositing
|
||||
alarm_scheduler.py # Loads and validates alarm config, checks firing schedule
|
||||
requirements.txt
|
||||
config/
|
||||
alarms.json # Alarm schedule configuration
|
||||
assets/
|
||||
alarm/ # WAV files for alarm audio
|
||||
img/ # Status images (idle.png, on_alarm.png)
|
||||
@@ -33,14 +36,15 @@ Dependencies: `websockets`, `psutil`, `Pillow`
|
||||
Start both servers:
|
||||
|
||||
```
|
||||
python run_all.py
|
||||
python run_all.py # both servers, default config
|
||||
python run_all.py --config path/to.json # both servers, custom config
|
||||
```
|
||||
|
||||
Or run individually:
|
||||
|
||||
```
|
||||
python stats_server.py # port 8765 only
|
||||
python contents_server.py # port 8766 only
|
||||
python contents_server.py --config path/to.json # port 8766, custom config
|
||||
python mock_server.py # port 8765, random data (no psutil needed)
|
||||
```
|
||||
|
||||
@@ -69,7 +73,7 @@ Serves alarm audio and status images. Protocol:
|
||||
2. Binary frames: raw PCM chunks (4096 bytes each, paced at ~90% real-time)
|
||||
3. Text frame: `{"type":"alarm_stop"}`
|
||||
|
||||
On connect, sends `idle.png` as the status image. Alarm cycles switch to `on_alarm.png` during playback, then back to `idle.png`.
|
||||
Loads alarm config from `config/alarms.json` (override with `--config`). Checks schedule every 5 seconds, fires once per matched minute. If no config or empty config, sends idle image and blocks forever. On alarm: switches to alarm image, streams audio, switches back to idle.
|
||||
|
||||
### mock_server.py -- port 8765
|
||||
|
||||
@@ -77,11 +81,43 @@ Same JSON schema and 2-second push interval as `stats_server.py`, but all values
|
||||
|
||||
Does not include `local_time` fields.
|
||||
|
||||
## Alarm Configuration
|
||||
|
||||
Config file: `config/alarms.json` -- a single alarm object or an array of alarm objects.
|
||||
|
||||
Example with two alarms:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"alarm_time": "0730",
|
||||
"alarm_days": ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
||||
"alarm_audio": "assets/alarm/alarm_test.wav",
|
||||
"alarm_image": "assets/img/on_alarm.png"
|
||||
},
|
||||
{
|
||||
"alarm_time": "2300",
|
||||
"alarm_audio": "assets/alarm/sleep.wav",
|
||||
"alarm_image": "assets/img/sleep.png"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `alarm_time` | `string` | Yes | 4-digit HHMM, 24-hour. Fires on the matched minute. |
|
||||
| `alarm_days` | `string[]` | No | 3-letter abbreviations: `Mon`–`Sun`. If omitted, fires every day. |
|
||||
| `alarm_dates` | `string[]` | No | `MM/DD` strings. Ignored if `alarm_days` is also set. |
|
||||
| `alarm_audio` | `string` | No | WAV path, relative to `pi/`. Default: `assets/alarm/alarm_test.wav`. |
|
||||
| `alarm_image` | `string` | No | Status PNG path, relative to `pi/`. Default: `assets/img/on_alarm.png`. |
|
||||
|
||||
If both `alarm_days` and `alarm_dates` are present, `alarm_days` takes priority.
|
||||
|
||||
## Modules
|
||||
|
||||
### audio_handler.py
|
||||
|
||||
- `find_wav()` -- finds the first `.wav` in `assets/alarm/`
|
||||
- `find_wav(path=None)` -- uses the given path if it exists, otherwise falls back to glob in `assets/alarm/`
|
||||
- `read_wav(path)` -- reads WAV, returns `(pcm_bytes, sample_rate, channels, bits)`
|
||||
- `stream_alarm(ws, pcm, sr, ch, bits)` -- streams one alarm cycle over WebSocket
|
||||
|
||||
@@ -89,3 +125,8 @@ Does not include `local_time` fields.
|
||||
|
||||
- `load_status_image(path)` -- loads PNG, composites transparency onto white, converts to 1-bit 120x120 monochrome bitmap (black=1, MSB-first)
|
||||
- `send_status_image(ws, img_bytes)` -- sends status image header + binary over WebSocket
|
||||
|
||||
### alarm_scheduler.py
|
||||
|
||||
- `load_config(path)` -- reads and validates alarm JSON; returns list of alarm dicts or `None`
|
||||
- `should_fire(config)` -- checks a single alarm entry against current local time
|
||||
|
||||
135
pi/alarm_scheduler.py
Normal file
135
pi/alarm_scheduler.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Alarm scheduler — load config and check firing schedule."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path(__file__).parent / "config" / "alarms.json"
|
||||
|
||||
VALID_DAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
TIME_RE = re.compile(r"^([01]\d|2[0-3])[0-5]\d$")
|
||||
DATE_RE = re.compile(r"^(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])$")
|
||||
|
||||
|
||||
def _validate_entry(entry: dict, index: int) -> dict | None:
|
||||
"""Validate a single alarm entry. Returns it if valid, None otherwise."""
|
||||
if not isinstance(entry, dict):
|
||||
log.warning("Alarm #%d: expected object, got %s", index, type(entry).__name__)
|
||||
return None
|
||||
|
||||
alarm_time = entry.get("alarm_time")
|
||||
if alarm_time is None:
|
||||
log.warning("Alarm #%d: missing required field 'alarm_time'", index)
|
||||
return None
|
||||
if not isinstance(alarm_time, str) or not TIME_RE.match(alarm_time):
|
||||
log.warning("Alarm #%d: invalid alarm_time '%s' — must be 4-digit HHMM", index, alarm_time)
|
||||
return None
|
||||
|
||||
alarm_days = entry.get("alarm_days")
|
||||
if alarm_days is not None:
|
||||
if not isinstance(alarm_days, list) or not all(isinstance(d, str) for d in alarm_days):
|
||||
log.warning("Alarm #%d: alarm_days must be a list of strings", index)
|
||||
return None
|
||||
bad = [d for d in alarm_days if d not in VALID_DAYS]
|
||||
if bad:
|
||||
log.warning("Alarm #%d: invalid day abbreviations: %s", index, bad)
|
||||
return None
|
||||
|
||||
alarm_dates = entry.get("alarm_dates")
|
||||
if alarm_dates is not None:
|
||||
if not isinstance(alarm_dates, list) or not all(isinstance(d, str) for d in alarm_dates):
|
||||
log.warning("Alarm #%d: alarm_dates must be a list of strings", index)
|
||||
return None
|
||||
bad = [d for d in alarm_dates if not DATE_RE.match(d)]
|
||||
if bad:
|
||||
log.warning("Alarm #%d: invalid date formats (expected MM/DD): %s", index, bad)
|
||||
return None
|
||||
|
||||
log.info("Alarm #%d: time=%s days=%s", index, alarm_time, alarm_days or "(every day)")
|
||||
return entry
|
||||
|
||||
|
||||
def load_config(path: Path) -> list[dict] | None:
|
||||
"""Read and validate alarm config JSON.
|
||||
|
||||
Accepts either a single alarm object or an array of alarm objects.
|
||||
Returns a list of valid alarm dicts, or None if the file is missing,
|
||||
empty, or contains no valid entries.
|
||||
Never raises — logs warnings and returns None on any problem.
|
||||
"""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
log.warning("Config file not found: %s", path)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("Cannot read config %s: %s", path, e)
|
||||
return None
|
||||
|
||||
if not text:
|
||||
log.warning("Config file is empty: %s", path)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
log.warning("Invalid JSON in %s: %s", path, e)
|
||||
return None
|
||||
|
||||
if not data:
|
||||
log.info("Config is empty — no alarms configured")
|
||||
return None
|
||||
|
||||
# Normalize to list
|
||||
if isinstance(data, dict):
|
||||
entries = [data]
|
||||
elif isinstance(data, list):
|
||||
entries = data
|
||||
else:
|
||||
log.warning("Config must be a JSON object or array, got %s", type(data).__name__)
|
||||
return None
|
||||
|
||||
valid = []
|
||||
for i, entry in enumerate(entries):
|
||||
result = _validate_entry(entry, i)
|
||||
if result is not None:
|
||||
valid.append(result)
|
||||
|
||||
if not valid:
|
||||
log.warning("No valid alarm entries in %s", path)
|
||||
return None
|
||||
|
||||
log.info("Loaded %d alarm(s) from %s", len(valid), path)
|
||||
return valid
|
||||
|
||||
|
||||
def should_fire(config: dict) -> bool:
|
||||
"""Check if a single alarm entry should fire right now.
|
||||
|
||||
Rules:
|
||||
- alarm_time must match current HHMM
|
||||
- If alarm_days is present, today's 3-letter abbreviation must be in the list
|
||||
- If alarm_days is absent but alarm_dates is present, today's MM/DD must match
|
||||
- If neither alarm_days nor alarm_dates is present, fires every day
|
||||
- If both are present, alarm_days wins (alarm_dates ignored)
|
||||
"""
|
||||
now = datetime.now()
|
||||
current_hhmm = now.strftime("%H%M")
|
||||
|
||||
if config["alarm_time"] != current_hhmm:
|
||||
return False
|
||||
|
||||
alarm_days = config.get("alarm_days")
|
||||
alarm_dates = config.get("alarm_dates")
|
||||
|
||||
if alarm_days is not None:
|
||||
return now.strftime("%a") in alarm_days
|
||||
|
||||
if alarm_dates is not None:
|
||||
return now.strftime("%m/%d") in alarm_dates
|
||||
|
||||
return True
|
||||
42
pi/assets/alarm/sleep.lab
Normal file
42
pi/assets/alarm/sleep.lab
Normal file
@@ -0,0 +1,42 @@
|
||||
0 850000 s
|
||||
850000 1580000 u
|
||||
1580000 2220000 i
|
||||
2220000 2660000 m
|
||||
2660000 3370000 i
|
||||
3370000 4140000 N
|
||||
4140000 4610000 g
|
||||
4610000 5780000 a
|
||||
5780000 13780000 pau
|
||||
13780000 14350000 k
|
||||
14350000 15010000 i
|
||||
15010000 15730000 e
|
||||
15730000 16160000 t
|
||||
16160000 16800000 e
|
||||
16800000 17260000 k
|
||||
17260000 17840000 u
|
||||
17840000 18390000 d
|
||||
18390000 19090000 a
|
||||
19090000 19700000 s
|
||||
19700000 20390000 a
|
||||
20390000 20830000 r
|
||||
20830000 22120000 i
|
||||
22120000 23620000 pau
|
||||
23620000 24390000 a
|
||||
24390000 24810000 r
|
||||
24810000 25430000 i
|
||||
25430000 25860000 g
|
||||
25860000 26550000 a
|
||||
26550000 27000000 t
|
||||
27000000 27780000 o
|
||||
27780000 28520000 o
|
||||
28520000 29000000 g
|
||||
29000000 29740000 o
|
||||
29740000 30260000 z
|
||||
30260000 31110000 a
|
||||
31110000 31790000 i
|
||||
31790000 32190000 m
|
||||
32190000 32870000 a
|
||||
32870000 33480000 sh
|
||||
33480000 34040000 i
|
||||
34040000 34520000 t
|
||||
34520000 35950000 a
|
||||
BIN
pi/assets/alarm/sleep.wav
Normal file
BIN
pi/assets/alarm/sleep.wav
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 33 KiB |
BIN
pi/assets/img/sleep.png
Normal file
BIN
pi/assets/img/sleep.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -1,8 +1,10 @@
|
||||
"""Audio alarm functions — WAV loading and PCM streaming."""
|
||||
|
||||
import array
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,8 +14,19 @@ CHUNK_SIZE = 4096
|
||||
AUDIO_DIR = Path(__file__).parent / "assets" / "alarm"
|
||||
|
||||
|
||||
def find_wav() -> Path:
|
||||
"""Find the first .wav file in the alarm assets directory."""
|
||||
def find_wav(path: Path | None = None) -> Path:
|
||||
"""Return a WAV file path.
|
||||
|
||||
If *path* is given and points to an existing file, use it directly.
|
||||
Otherwise fall back to the first .wav found in the alarm assets directory.
|
||||
"""
|
||||
if path is not None:
|
||||
p = Path(path)
|
||||
if p.is_file():
|
||||
log.info("Using audio file: %s", p)
|
||||
return p
|
||||
log.warning("Specified audio path not found: %s — falling back to glob", p)
|
||||
|
||||
wavs = list(AUDIO_DIR.glob("*.wav"))
|
||||
if not wavs:
|
||||
raise FileNotFoundError(f"No .wav files found in {AUDIO_DIR}")
|
||||
@@ -21,15 +34,52 @@ def find_wav() -> Path:
|
||||
return wavs[0]
|
||||
|
||||
|
||||
def _normalize_pcm(pcm: bytes, bits: int) -> bytes:
|
||||
"""Peak-normalize PCM data to 0 dBFS.
|
||||
|
||||
Supports 8-bit (unsigned) and 16-bit (signed) PCM.
|
||||
Returns the original bytes unchanged if already at 0 dB or silent.
|
||||
"""
|
||||
if bits == 16:
|
||||
samples = array.array("h", pcm) # signed 16-bit
|
||||
peak = max(abs(s) for s in samples) if samples else 0
|
||||
if peak == 0 or peak == 32767:
|
||||
return pcm
|
||||
scale = 32767 / peak
|
||||
samples = array.array("h", (min(32767, max(-32768, int(s * scale))) for s in samples))
|
||||
elif bits == 8:
|
||||
samples = array.array("B", pcm) # unsigned 8-bit, center=128
|
||||
peak = max(abs(s - 128) for s in samples) if samples else 0
|
||||
if peak == 0 or peak == 127:
|
||||
return pcm
|
||||
scale = 127 / peak
|
||||
samples = array.array("B", (max(0, min(255, int((s - 128) * scale) + 128)) for s in samples))
|
||||
else:
|
||||
log.warning("Normalization not supported for %d-bit audio, skipping", bits)
|
||||
return pcm
|
||||
|
||||
gain_db = 20 * __import__("math").log10(scale) if scale > 0 else 0
|
||||
log.info("Normalized: peak %d → 0 dBFS (gain %.1f dB)", peak, gain_db)
|
||||
return samples.tobytes()
|
||||
|
||||
|
||||
def read_wav(path: Path) -> tuple[bytes, int, int, int]:
|
||||
"""Read WAV file and return (pcm_data, sample_rate, channels, bits_per_sample)."""
|
||||
with wave.open(str(path), "rb") as wf:
|
||||
"""Read WAV file, normalize to 0 dBFS, return (pcm_data, sample_rate, channels, bits)."""
|
||||
try:
|
||||
wf = wave.open(str(path), "rb")
|
||||
except wave.Error as e:
|
||||
raise ValueError(
|
||||
f"{path.name}: unsupported WAV format ({e}). "
|
||||
"Only 8/16-bit integer PCM is supported — no 32-bit float."
|
||||
) from e
|
||||
with wf:
|
||||
sr = wf.getframerate()
|
||||
ch = wf.getnchannels()
|
||||
bits = wf.getsampwidth() * 8
|
||||
pcm = wf.readframes(wf.getnframes())
|
||||
log.info("WAV loaded: %dHz %dch %dbit, %.1fs, %d bytes",
|
||||
sr, ch, bits, len(pcm) / (sr * ch * (bits // 8)), len(pcm))
|
||||
pcm = _normalize_pcm(pcm, bits)
|
||||
return pcm, sr, ch, bits
|
||||
|
||||
|
||||
|
||||
13
pi/config/alarms.json
Normal file
13
pi/config/alarms.json
Normal file
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"alarm_time": "0700",
|
||||
"alarm_days": ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
||||
"alarm_audio": "assets/alarm/alarm_test.wav",
|
||||
"alarm_image": "assets/img/on_alarm.png"
|
||||
},
|
||||
{
|
||||
"alarm_time": "2330",
|
||||
"alarm_audio": "assets/alarm/sleep.wav",
|
||||
"alarm_image": "assets/img/sleep.png"
|
||||
}
|
||||
]
|
||||
@@ -6,7 +6,7 @@ connected ESP32 dashboard client on port 8766.
|
||||
|
||||
Protocol:
|
||||
Status image:
|
||||
1. Text frame: {"type":"status_image","width":120,"height":120}
|
||||
1. Text frame: {"type":"status_image","width":200,"height":200}
|
||||
2. Binary frame: 1-bit monochrome bitmap
|
||||
|
||||
Alarm audio:
|
||||
@@ -15,12 +15,16 @@ Protocol:
|
||||
3. Text frame: {"type":"alarm_stop"}
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from random import randint
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import websockets
|
||||
|
||||
from alarm_scheduler import DEFAULT_CONFIG_PATH, load_config, should_fire
|
||||
from audio_handler import find_wav, read_wav, stream_alarm
|
||||
from image_handler import IMG_DIR, load_status_image, send_status_image
|
||||
|
||||
@@ -28,6 +32,34 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(mess
|
||||
log = logging.getLogger("contents_server")
|
||||
|
||||
PORT = 8766
|
||||
PI_DIR = Path(__file__).parent
|
||||
|
||||
# Set by main(), read by handler()
|
||||
_config_path: Path = DEFAULT_CONFIG_PATH
|
||||
|
||||
TICK_INTERVAL = 5 # seconds between schedule checks
|
||||
|
||||
|
||||
def _resolve_path(relative: str) -> Path:
|
||||
"""Resolve a config path relative to pi/ directory."""
|
||||
p = Path(relative)
|
||||
if not p.is_absolute():
|
||||
p = PI_DIR / p
|
||||
return p
|
||||
|
||||
|
||||
def _prepare_alarm(entry: dict) -> dict:
|
||||
"""Pre-resolve paths and load resources for a single alarm entry."""
|
||||
audio_path = find_wav(_resolve_path(entry.get("alarm_audio", "assets/alarm/alarm_test.wav")))
|
||||
alarm_img_path = _resolve_path(entry.get("alarm_image", "assets/img/on_alarm.png"))
|
||||
pcm, sr, ch, bits = read_wav(audio_path)
|
||||
img = load_status_image(alarm_img_path)
|
||||
return {
|
||||
"config": entry,
|
||||
"pcm": pcm, "sr": sr, "ch": ch, "bits": bits,
|
||||
"img": img,
|
||||
"last_fired": None,
|
||||
}
|
||||
|
||||
|
||||
async def handler(ws):
|
||||
@@ -35,27 +67,50 @@ async def handler(ws):
|
||||
remote = ws.remote_address
|
||||
log.info("Client connected: %s:%d", remote[0], remote[1])
|
||||
|
||||
wav_path = find_wav()
|
||||
pcm, sr, ch, bits = read_wav(wav_path)
|
||||
|
||||
# Load status images
|
||||
configs = load_config(_config_path)
|
||||
img_idle = load_status_image(IMG_DIR / "idle.png")
|
||||
img_alarm = load_status_image(IMG_DIR / "on_alarm.png")
|
||||
current_img = img_idle
|
||||
|
||||
try:
|
||||
# Send idle image on connect
|
||||
await send_status_image(ws, img_idle)
|
||||
alarms = [_prepare_alarm(entry) for entry in configs] if configs else []
|
||||
|
||||
async def alarm_ticker():
|
||||
nonlocal current_img
|
||||
if not alarms:
|
||||
log.info("No alarms configured — idling forever")
|
||||
await asyncio.Future()
|
||||
return
|
||||
|
||||
while True:
|
||||
delay = randint(30, 60)
|
||||
log.info("Next alarm in %ds", delay)
|
||||
await asyncio.sleep(delay)
|
||||
for alarm in alarms:
|
||||
if should_fire(alarm["config"]):
|
||||
current_minute = datetime.now().strftime("%Y%m%d%H%M")
|
||||
|
||||
# Switch to alarm image before audio
|
||||
await send_status_image(ws, img_alarm)
|
||||
await stream_alarm(ws, pcm, sr, ch, bits)
|
||||
# Switch back to idle after alarm
|
||||
await send_status_image(ws, img_idle)
|
||||
if current_minute != alarm["last_fired"]:
|
||||
alarm["last_fired"] = current_minute
|
||||
log.info("Alarm firing: %s at %s",
|
||||
alarm["config"]["alarm_time"], current_minute)
|
||||
current_img = alarm["img"]
|
||||
await send_status_image(ws, current_img)
|
||||
await stream_alarm(ws, alarm["pcm"], alarm["sr"],
|
||||
alarm["ch"], alarm["bits"])
|
||||
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])
|
||||
|
||||
@@ -63,8 +118,13 @@ async def handler(ws):
|
||||
async def main():
|
||||
log.info("Contents server starting on port %d", PORT)
|
||||
async with websockets.serve(handler, "0.0.0.0", PORT):
|
||||
await asyncio.Future() # run forever
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Alarm contents server")
|
||||
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_PATH,
|
||||
help="Path to alarm config JSON (default: %(default)s)")
|
||||
args = parser.parse_args()
|
||||
_config_path = args.config
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -9,12 +9,12 @@ from PIL import Image
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
IMG_DIR = Path(__file__).parent / "assets" / "img"
|
||||
STATUS_IMG_SIZE = 120
|
||||
MONOCHROME_THRESHOLD = 120
|
||||
STATUS_IMG_SIZE = 200
|
||||
MONOCHROME_THRESHOLD = 180
|
||||
|
||||
|
||||
def load_status_image(path: Path) -> bytes:
|
||||
"""Load a PNG, convert to 1-bit 120x120 monochrome bitmap (MSB-first, black=1).
|
||||
"""Load a PNG, convert to 1-bit 200x200 monochrome bitmap (MSB-first, black=1).
|
||||
|
||||
Transparent pixels are composited onto white so they don't render as black.
|
||||
"""
|
||||
@@ -37,7 +37,7 @@ def load_status_image(path: Path) -> bytes:
|
||||
y_off = (STATUS_IMG_SIZE - img.height) // 2
|
||||
canvas.paste(img, (x_off, y_off))
|
||||
|
||||
# Threshold to 1-bit: black (< 128) -> 1, white -> 0
|
||||
# Threshold to 1-bit: black (< MONOCHROME_THRESHOLD) -> 1, white -> 0
|
||||
bw = canvas.point(lambda p: 1 if p < MONOCHROME_THRESHOLD else 0, "1")
|
||||
raw = bw.tobytes()
|
||||
log.info("Status image loaded: %s -> %d bytes", path.name, len(raw))
|
||||
|
||||
@@ -4,9 +4,13 @@ import subprocess, sys, signal
|
||||
from pathlib import Path
|
||||
|
||||
d = Path(__file__).parent
|
||||
|
||||
# Forward any CLI args (e.g. --config) to contents_server
|
||||
extra_args = sys.argv[1:]
|
||||
|
||||
procs = [
|
||||
subprocess.Popen([sys.executable, d / "stats_server.py"]),
|
||||
subprocess.Popen([sys.executable, d / "contents_server.py"]),
|
||||
subprocess.Popen([sys.executable, d / "contents_server.py"] + extra_args),
|
||||
]
|
||||
signal.signal(signal.SIGINT, lambda *_: [p.terminate() for p in procs])
|
||||
signal.signal(signal.SIGTERM, lambda *_: [p.terminate() for p in procs])
|
||||
|
||||
@@ -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,15 +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"},
|
||||
]
|
||||
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:
|
||||
@@ -86,7 +123,7 @@ def _local_time_fields() -> dict:
|
||||
|
||||
def generate_stats() -> dict:
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage("/")
|
||||
disk = psutil.disk_usage("/mnt/buffalo")
|
||||
rx_kbps, tx_kbps = _get_net_throughput()
|
||||
|
||||
return {
|
||||
@@ -96,9 +133,9 @@ def generate_stats() -> dict:
|
||||
"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(),
|
||||
"net_rx_kbps": rx_kbps / 8,
|
||||
"net_tx_kbps": tx_kbps / 8, # kByte/s for humans
|
||||
"services": _get_docker_services(),
|
||||
"timestamp": int(time.time()),
|
||||
"local_time": _local_time_fields(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user