Compare commits
34 Commits
dca989a01b
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c48d70284 | |||
| 436e55a0c5 | |||
|
|
776aee18fc | ||
|
|
41e0a6b81d | ||
|
|
e4da3687b4 | ||
|
|
05cd085b89 | ||
|
|
d0d0b4dc39 | ||
|
|
25420d57b3 | ||
|
|
18984c29a3 | ||
|
|
7555efcba9 | ||
| 379f8e105b | |||
| 3b4d61c56d | |||
| 5ae0c64ba9 | |||
|
|
7f644652bb | ||
| 5c16e6deb7 | |||
|
|
706c7ac21b | ||
| b33c658885 | |||
|
|
2e5ad58978 | ||
|
|
8ffe8cdffb | ||
|
|
b0a5e05e4a | ||
|
|
4e2c714e1f | ||
|
|
7165f56464 | ||
|
|
a25e4b2cb3 | ||
|
|
76bf8c966d | ||
|
|
d63322d304 | ||
|
|
0b2f274719 | ||
|
|
22c32f3538 | ||
|
|
89c975bf17 | ||
|
|
e5cc124dd3 | ||
|
|
a0cec91845 | ||
|
|
1140de0fd4 | ||
|
|
37bdc8bb1b | ||
|
|
33936650c6 | ||
|
|
7eb05ea983 |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(echo:*)",
|
||||
"Bash(idf.py build:*)",
|
||||
"Bash(for f in DSEG14C_BI_50px.c InziuIosevka_Slab_CC_12px.c InziuIosevka_Slab_CC_16px.c InziuIosevka_Slab_CC_20px.c InziuIosevka_Slab_CC_24px.c InziuIosevka_Slab_CC_32px.c)",
|
||||
"Bash(do sed -i '/\\\\.static_bitmap = 0,/d' \"$f\")",
|
||||
"Bash(done)"
|
||||
]
|
||||
},
|
||||
"outputStyle": "iseri",
|
||||
"spinnerTipsEnabled": false,
|
||||
"prefersReducedMotion": true
|
||||
}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,3 +3,9 @@ managed_components/
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
dependencies.lock
|
||||
|
||||
# vscode local settings
|
||||
.vscode/
|
||||
|
||||
# claude local settings
|
||||
.claude/
|
||||
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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "audio_client.cpp"
|
||||
REQUIRES espressif__esp_websocket_client port_bsp
|
||||
REQUIRES espressif__esp_websocket_client port_bsp codec_board lvgl__lvgl
|
||||
PRIV_REQUIRES esp_event json
|
||||
INCLUDE_DIRS "./")
|
||||
|
||||
@@ -18,6 +18,11 @@ static const char *TAG = "audio_client";
|
||||
#define PLAYBACK_PRIORITY 4
|
||||
#define WS_BUFFER_SIZE 8192
|
||||
|
||||
/* Status image constants */
|
||||
#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;
|
||||
static QueueHandle_t s_pcm_queue = NULL;
|
||||
@@ -25,6 +30,14 @@ static TaskHandle_t s_playback_task = NULL;
|
||||
static volatile audio_state_t s_state = AUDIO_IDLE;
|
||||
static volatile bool s_playing = false;
|
||||
|
||||
/* Status image state */
|
||||
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);
|
||||
static void ws_event_handler(void *arg, esp_event_base_t event_base,
|
||||
@@ -56,6 +69,13 @@ static void handle_text_frame(const char *data, int len)
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(type->valuestring, "status_image") == 0) {
|
||||
ESP_LOGI(TAG, "Status image header received");
|
||||
s_img_pending = true;
|
||||
cJSON_Delete(root);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(type->valuestring, "alarm_start") == 0) {
|
||||
int sr = 24000;
|
||||
int ch = 2;
|
||||
@@ -98,9 +118,25 @@ static void handle_text_frame(const char *data, int len)
|
||||
|
||||
static void handle_binary_frame(const uint8_t *data, int len)
|
||||
{
|
||||
/* Status image binary payload */
|
||||
if (s_img_pending) {
|
||||
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);
|
||||
}
|
||||
s_img_pending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!s_playing) return;
|
||||
|
||||
uint8_t *chunk = heap_caps_malloc(len, MALLOC_CAP_SPIRAM);
|
||||
uint8_t *chunk = (uint8_t *)heap_caps_malloc(len, MALLOC_CAP_SPIRAM);
|
||||
if (!chunk) {
|
||||
ESP_LOGW(TAG, "PSRAM alloc failed (%d bytes)", len);
|
||||
return;
|
||||
@@ -122,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;
|
||||
@@ -172,6 +214,14 @@ void audio_client_init(const char *uri, void *codec)
|
||||
{
|
||||
s_codec = (CodecPort *)codec;
|
||||
|
||||
/* Initialize status image descriptor */
|
||||
memset(&s_img_dsc, 0, sizeof(s_img_dsc));
|
||||
s_img_dsc.header.cf = LV_IMG_CF_ALPHA_1BIT;
|
||||
s_img_dsc.header.w = STATUS_IMG_W;
|
||||
s_img_dsc.header.h = STATUS_IMG_H;
|
||||
s_img_dsc.data_size = STATUS_IMG_BYTES;
|
||||
s_img_dsc.data = s_img_buf;
|
||||
|
||||
s_pcm_queue = xQueueCreate(PCM_QUEUE_DEPTH, sizeof(uint8_t *));
|
||||
if (!s_pcm_queue) {
|
||||
ESP_LOGE(TAG, "Failed to create PCM queue");
|
||||
@@ -222,3 +272,35 @@ 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;
|
||||
}
|
||||
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,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include "lvgl.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -29,6 +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.
|
||||
* @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,12 +39,16 @@ 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 */
|
||||
static lv_obj_t *lbl_room_temp;
|
||||
static lv_obj_t *lbl_room_humi;
|
||||
/* Local sensors (bottom bar) */
|
||||
static lv_obj_t *lbl_local;
|
||||
static lv_obj_t *lbl_uptime;
|
||||
|
||||
/* Status image placeholder */
|
||||
static lv_obj_t *img_status;
|
||||
|
||||
/* Network */
|
||||
static lv_obj_t *lbl_net;
|
||||
|
||||
@@ -113,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 ---------- */
|
||||
@@ -158,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);
|
||||
@@ -167,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, -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);
|
||||
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);
|
||||
@@ -200,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);
|
||||
@@ -227,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, 120, 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");
|
||||
@@ -267,11 +323,12 @@ 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");
|
||||
|
||||
/* === Local Sensors (below pi vitals) === */
|
||||
int sy = ry + row_h + 4;
|
||||
create_label(parent, rx + 4, sy, &InziuIosevka_Slab_CC_12px, "LOCAL SENSORS");
|
||||
lbl_room_temp = create_label(parent, rx + 4, sy + 16, &InziuIosevka_Slab_CC_16px, "Room: --.-C");
|
||||
lbl_room_humi = create_label(parent, rx + 4, sy + 42, &InziuIosevka_Slab_CC_16px, "Humi: --%");
|
||||
/* === Right column: Status image (200x200) === */
|
||||
img_status = lv_img_create(parent);
|
||||
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);
|
||||
}
|
||||
|
||||
static void create_bottom_bar(lv_obj_t *parent)
|
||||
@@ -281,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);
|
||||
@@ -292,9 +349,16 @@ 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_white(), 0);
|
||||
lv_obj_align(lbl_local, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_label_set_text(lbl_local, "T: --.- H: --%");
|
||||
}
|
||||
|
||||
void dashboard_ui_create(void)
|
||||
@@ -340,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);
|
||||
@@ -356,23 +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), "Room: %.1fC", temp);
|
||||
lv_label_set_text(lbl_room_temp, buf);
|
||||
snprintf(buf, sizeof(buf), "T: %.1f H: %.0f%%", temp, humidity);
|
||||
lv_label_set_text(lbl_local, buf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "Humi: %.0f%%", humidity);
|
||||
lv_label_set_text(lbl_room_humi, 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);
|
||||
}
|
||||
@@ -406,3 +473,10 @@ void dashboard_ui_update_connection(ws_state_t ws_state, bool wifi_connected, co
|
||||
}
|
||||
lv_label_set_text(lbl_ws, ws_str);
|
||||
}
|
||||
|
||||
void dashboard_ui_update_status_image(const lv_img_dsc_t *dsc)
|
||||
{
|
||||
if (dsc) {
|
||||
lv_img_set_src(img_status, dsc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -35,6 +35,12 @@ void dashboard_ui_update_time(int h, int m, int s, int year, int month, int day,
|
||||
*/
|
||||
void dashboard_ui_update_connection(ws_state_t ws_state, bool wifi_connected, const char *ip_str);
|
||||
|
||||
/**
|
||||
* Update the status image widget. LVGL lock must be held by caller.
|
||||
* @param dsc Image descriptor (1-bit monochrome, 120x120)
|
||||
*/
|
||||
void dashboard_ui_update_status_image(const lv_img_dsc_t *dsc);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
|
||||
void Adc_PortInit();
|
||||
float Adc_GetBatteryVoltage();
|
||||
uint8_t Adc_GetBatteryLevel();
|
||||
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,24 +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();
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,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)
|
||||
@@ -232,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);
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# Alarm Audio Streaming Protocol
|
||||
|
||||
## Overview
|
||||
|
||||
The Raspberry Pi streams alarm audio to the ESP32-S3 over a dedicated WebSocket connection on **port 8766**. The protocol uses mixed text and binary frames — text for control messages, binary for raw PCM data.
|
||||
|
||||
## Connection
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Port | 8766 |
|
||||
| Transport | WebSocket |
|
||||
| Direction | Pi (server) → ESP32 (client) |
|
||||
|
||||
The ESP32 connects and stays connected. The server initiates alarm playback when needed.
|
||||
|
||||
## Message Sequence
|
||||
|
||||
```
|
||||
Pi ESP32
|
||||
│ │
|
||||
│◄──── WS connect ──────────│
|
||||
│ │
|
||||
│ (idle until alarm fires) │
|
||||
│ │
|
||||
├─ alarm_start (text) ──────►│ → open codec
|
||||
├─ PCM chunk (binary) ──────►│ → queue + play
|
||||
├─ PCM chunk (binary) ──────►│
|
||||
│ ... │
|
||||
├─ alarm_stop (text) ────────►│ → drain + close codec
|
||||
│ │
|
||||
```
|
||||
|
||||
## Control Messages (Text Frames)
|
||||
|
||||
### alarm_start
|
||||
|
||||
Sent before the first PCM chunk. The ESP32 uses these parameters to configure the DAC.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "alarm_start",
|
||||
"sample_rate": 24000,
|
||||
"channels": 2,
|
||||
"bits": 16
|
||||
}
|
||||
```
|
||||
|
||||
### alarm_stop
|
||||
|
||||
Sent after the last PCM chunk.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "alarm_stop"
|
||||
}
|
||||
```
|
||||
|
||||
## PCM Data (Binary Frames)
|
||||
|
||||
- **Format:** Raw signed 16-bit little-endian, interleaved stereo (L, R, L, R, ...)
|
||||
- **Chunk size:** 4096 bytes (1024 stereo samples at 16-bit)
|
||||
- **Pacing:** Chunks sent at ~90% real-time speed to maintain buffer without overflow
|
||||
- **Byte order:** Little-endian (native for both Pi and ESP32)
|
||||
|
||||
At 24kHz / 2ch / 16-bit:
|
||||
- Bytes per second: 96,000
|
||||
- Chunk duration: ~42.7 ms
|
||||
- Send interval: ~38.4 ms (90% pacing)
|
||||
|
||||
## ESP32 Buffering
|
||||
|
||||
- FreeRTOS queue: 10 slots of pointer-to-chunk
|
||||
- Total buffer capacity: ~427 ms of audio
|
||||
- Overflow policy: drop chunk and log warning
|
||||
|
||||
## Future: Alarm Scheduling
|
||||
|
||||
Not yet implemented. Planned JSON format for alarm configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"alarm_time": "070000",
|
||||
"alarm_days": ["Mon", "Tue", "Wed"],
|
||||
"alarm_audio": "path/to/file.wav"
|
||||
}
|
||||
```
|
||||
5
pi/.gitignore
vendored
5
pi/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
# python artifacts
|
||||
*/__pycache__
|
||||
__pycache__/
|
||||
*.pyo
|
||||
*.pyc
|
||||
@@ -1,55 +0,0 @@
|
||||
0 700000 j
|
||||
700000 1440000 u
|
||||
1440000 2370000 u
|
||||
2370000 3110000 i
|
||||
3110000 3710000 ch
|
||||
3710000 4380000 i
|
||||
4380000 4800000 g
|
||||
4800000 5560000 a
|
||||
5560000 6180000 ts
|
||||
6180000 6850000 u
|
||||
6850000 7500000 j
|
||||
7500000 8210000 u
|
||||
8210000 9130000 u
|
||||
9130000 9880000 i
|
||||
9880000 10460000 ch
|
||||
10460000 11150000 i
|
||||
11150000 11690000 g
|
||||
11690000 12470000 a
|
||||
12470000 13100000 ts
|
||||
13100000 13770000 u
|
||||
13770000 14420000 j
|
||||
14420000 15140000 u
|
||||
15140000 16070000 u
|
||||
16070000 16810000 i
|
||||
16810000 17420000 ch
|
||||
17420000 18080000 i
|
||||
18080000 18610000 g
|
||||
18610000 19410000 a
|
||||
19410000 20020000 ts
|
||||
20020000 20680000 u
|
||||
20680000 21320000 j
|
||||
21320000 22030000 u
|
||||
22030000 22900000 u
|
||||
22900000 23640000 i
|
||||
23640000 24250000 ch
|
||||
24250000 24920000 i
|
||||
24920000 25460000 g
|
||||
25460000 26200000 a
|
||||
26200000 26840000 ts
|
||||
26840000 27480000 u
|
||||
27480000 28130000 j
|
||||
28130000 28830000 u
|
||||
28830000 29720000 u
|
||||
29720000 30440000 i
|
||||
30440000 31040000 ch
|
||||
31040000 31750000 i
|
||||
31750000 32600000 by
|
||||
32600000 33320000 o
|
||||
33320000 34120000 o
|
||||
34120000 34740000 j
|
||||
34740000 35350000 a
|
||||
35350000 35870000 s
|
||||
35870000 36510000 u
|
||||
36510000 36960000 t
|
||||
36960000 38220000 o
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,112 +0,0 @@
|
||||
"""
|
||||
Alarm audio streaming test server.
|
||||
|
||||
Streams a WAV file as raw PCM chunks over WebSocket on port 8766.
|
||||
Repeats every 30-60 seconds to exercise the ESP32 audio pipeline.
|
||||
|
||||
Protocol:
|
||||
1. Text frame: {"type":"alarm_start","sample_rate":N,"channels":N,"bits":N}
|
||||
2. Binary frames: raw PCM chunks (4096 bytes each, paced at ~90% real-time)
|
||||
3. Text frame: {"type":"alarm_stop"}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import struct
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from random import randint
|
||||
|
||||
import websockets
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("audio_server")
|
||||
|
||||
PORT = 8766
|
||||
CHUNK_SIZE = 4096
|
||||
AUDIO_DIR = Path(__file__).parent / "assets" / "alarm"
|
||||
|
||||
|
||||
def find_wav() -> Path:
|
||||
"""Find the first .wav file in the alarm assets directory."""
|
||||
wavs = list(AUDIO_DIR.glob("*.wav"))
|
||||
if not wavs:
|
||||
raise FileNotFoundError(f"No .wav files found in {AUDIO_DIR}")
|
||||
log.info("Using audio file: %s", wavs[0].name)
|
||||
return wavs[0]
|
||||
|
||||
|
||||
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:
|
||||
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))
|
||||
return pcm, sr, ch, bits
|
||||
|
||||
|
||||
def chunk_bytes(data: bytes, size: int):
|
||||
"""Yield data in fixed-size chunks."""
|
||||
for i in range(0, len(data), size):
|
||||
yield data[i : i + size]
|
||||
|
||||
|
||||
async def stream_alarm(ws, pcm: bytes, sr: int, ch: int, bits: int):
|
||||
"""Stream one alarm cycle to the connected client."""
|
||||
# Compute pacing: how long each chunk represents in seconds
|
||||
bytes_per_sec = sr * ch * (bits // 8)
|
||||
chunk_duration = CHUNK_SIZE / bytes_per_sec
|
||||
pace_delay = chunk_duration * 0.9 # 90% real-time to avoid underrun
|
||||
|
||||
total_chunks = (len(pcm) + CHUNK_SIZE - 1) // CHUNK_SIZE
|
||||
|
||||
# Start
|
||||
start_msg = json.dumps({
|
||||
"type": "alarm_start",
|
||||
"sample_rate": sr,
|
||||
"channels": ch,
|
||||
"bits": bits,
|
||||
})
|
||||
await ws.send(start_msg)
|
||||
log.info("Sent alarm_start (%d chunks, pace %.1fms)", total_chunks, pace_delay * 1000)
|
||||
|
||||
# Stream PCM chunks
|
||||
for i, chunk in enumerate(chunk_bytes(pcm, CHUNK_SIZE)):
|
||||
await ws.send(chunk) # bytes → binary frame
|
||||
await asyncio.sleep(pace_delay)
|
||||
|
||||
# Stop
|
||||
await ws.send(json.dumps({"type": "alarm_stop"}))
|
||||
log.info("Sent alarm_stop")
|
||||
|
||||
|
||||
async def handler(ws):
|
||||
"""Handle a single WebSocket connection."""
|
||||
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)
|
||||
|
||||
try:
|
||||
while True:
|
||||
delay = randint(30, 60)
|
||||
log.info("Next alarm in %ds", delay)
|
||||
await asyncio.sleep(delay)
|
||||
await stream_alarm(ws, pcm, sr, ch, bits)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
log.info("Client disconnected: %s:%d", remote[0], remote[1])
|
||||
|
||||
|
||||
async def main():
|
||||
log.info("Audio server starting on port %d", PORT)
|
||||
async with websockets.serve(handler, "0.0.0.0", PORT):
|
||||
await asyncio.Future() # run forever
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_loop = asyncio.run(main())
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mock WebSocket server that sends randomized Pi stats every 2 seconds."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
|
||||
def generate_stats():
|
||||
services = [
|
||||
{"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"},
|
||||
]
|
||||
return {
|
||||
"cpu_pct": round(random.uniform(5, 95), 1),
|
||||
"mem_pct": round(random.uniform(30, 85), 1),
|
||||
"mem_used_mb": random.randint(512, 3200),
|
||||
"disk_pct": round(random.uniform(20, 80), 1),
|
||||
"cpu_temp": round(random.uniform(35, 78), 1),
|
||||
"uptime_hrs": round(random.uniform(1, 2000), 1),
|
||||
"net_rx_kbps": round(random.uniform(0, 5000), 1),
|
||||
"net_tx_kbps": round(random.uniform(0, 2000), 1),
|
||||
"services": services,
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
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("Mock 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())
|
||||
@@ -1,2 +0,0 @@
|
||||
websockets>=12.0
|
||||
psutil>=5.9.0
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch stats_server and audio_server as child processes."""
|
||||
import subprocess, sys, signal
|
||||
from pathlib import Path
|
||||
|
||||
d = Path(__file__).parent
|
||||
procs = [
|
||||
subprocess.Popen([sys.executable, d / "stats_server.py"]),
|
||||
subprocess.Popen([sys.executable, d / "audio_server.py"]),
|
||||
]
|
||||
signal.signal(signal.SIGINT, lambda *_: [p.terminate() for p in procs])
|
||||
signal.signal(signal.SIGTERM, lambda *_: [p.terminate() for p in procs])
|
||||
print(f"Running stats_server (PID {procs[0].pid}) + audio_server (PID {procs[1].pid})")
|
||||
for p in procs:
|
||||
p.wait()
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/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