diff --git a/.claude/settings.local.json b/.claude/settings.local.json index baae4d7..9077af7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "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)", - "Bash(file:*)" + "Bash(file:*)", + "mcp__ide__getDiagnostics" ] }, "outputStyle": "iseri", diff --git a/components/audio_client/CMakeLists.txt b/components/audio_client/CMakeLists.txt index 60e0f74..842dff4 100644 --- a/components/audio_client/CMakeLists.txt +++ b/components/audio_client/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRCS "audio_client.cpp" - REQUIRES espressif__esp_websocket_client port_bsp codec_board + REQUIRES espressif__esp_websocket_client port_bsp codec_board lvgl__lvgl PRIV_REQUIRES esp_event json INCLUDE_DIRS "./") diff --git a/components/audio_client/audio_client.cpp b/components/audio_client/audio_client.cpp index 37de543..cf77a43 100644 --- a/components/audio_client/audio_client.cpp +++ b/components/audio_client/audio_client.cpp @@ -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 120 +#define STATUS_IMG_H 120 +#define STATUS_IMG_BYTES (STATUS_IMG_W * STATUS_IMG_H / 8) /* 1800 */ + static esp_websocket_client_handle_t s_client = NULL; static CodecPort *s_codec = NULL; static QueueHandle_t s_pcm_queue = NULL; @@ -25,6 +30,12 @@ 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 */ + /* Forward declarations */ static void playback_task(void *arg); static void ws_event_handler(void *arg, esp_event_base_t event_base, @@ -56,6 +67,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,6 +116,19 @@ 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; + 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 = (uint8_t *)heap_caps_malloc(len, MALLOC_CAP_SPIRAM); @@ -172,6 +203,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 +261,14 @@ audio_state_t audio_client_get_state(void) { return s_state; } + +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; +} diff --git a/components/audio_client/audio_client.h b/components/audio_client/audio_client.h index de1192c..c5c0866 100644 --- a/components/audio_client/audio_client.h +++ b/components/audio_client/audio_client.h @@ -1,6 +1,7 @@ #pragma once #include +#include "lvgl.h" #ifdef __cplusplus extern "C" { @@ -29,6 +30,13 @@ void audio_client_stop(void); /** Get current audio client state. */ audio_state_t audio_client_get_state(void); +/** + * Get the latest status image descriptor. + * @param updated Set to true if a new image arrived since last call, then reset. + * @return Pointer to the static image descriptor (always valid). + */ +const lv_img_dsc_t *audio_client_get_status_image(bool *updated); + #ifdef __cplusplus } #endif diff --git a/components/dashboard_ui/dashboard_ui.c b/components/dashboard_ui/dashboard_ui.c index c5a7e8a..f507f30 100644 --- a/components/dashboard_ui/dashboard_ui.c +++ b/components/dashboard_ui/dashboard_ui.c @@ -39,11 +39,13 @@ static lv_obj_t *lbl_cpu_temp; static lv_obj_t *tbl_services; static int s_service_count; -/* 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; @@ -267,11 +269,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"); - /* === 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: --%"); + /* === Status image (120x120, bottom-right above bot bar) === */ + img_status = lv_img_create(parent); + lv_obj_set_pos(img_status, 280, 156); + lv_obj_set_size(img_status, 120, 120); } static void create_bottom_bar(lv_obj_t *parent) @@ -295,6 +296,13 @@ static void create_bottom_bar(lv_obj_t *parent) 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"); + + /* Local sensor readings — right-aligned */ + lbl_local = lv_label_create(bot_cont); + lv_obj_set_style_text_font(lbl_local, &InziuIosevka_Slab_CC_12px, 0); + lv_obj_set_style_text_color(lbl_local, lv_color_black(), 0); + lv_obj_align(lbl_local, LV_ALIGN_RIGHT_MID, 0, 0); + lv_label_set_text(lbl_local, "T: --.- H: --%"); } void dashboard_ui_create(void) @@ -365,11 +373,8 @@ void dashboard_ui_update_local(float temp, float humidity, uint8_t battery) { char buf[32]; - snprintf(buf, sizeof(buf), "Room: %.1fC", temp); - lv_label_set_text(lbl_room_temp, buf); - - snprintf(buf, sizeof(buf), "Humi: %.0f%%", humidity); - lv_label_set_text(lbl_room_humi, buf); + snprintf(buf, sizeof(buf), "T: %.1f H: %.0f%%", temp, humidity); + lv_label_set_text(lbl_local, buf); /* Update top bar battery text + bar */ snprintf(buf, sizeof(buf), "%d%%", battery); @@ -406,3 +411,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); + } +} diff --git a/components/dashboard_ui/dashboard_ui.h b/components/dashboard_ui/dashboard_ui.h index bcadc54..22b1fb3 100644 --- a/components/dashboard_ui/dashboard_ui.h +++ b/components/dashboard_ui/dashboard_ui.h @@ -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 diff --git a/components/user_app/user_app.cpp b/components/user_app/user_app.cpp index 559c2cb..4a100bc 100644 --- a/components/user_app/user_app.cpp +++ b/components/user_app/user_app.cpp @@ -211,6 +211,14 @@ static void sensor_task(void *arg) 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)); } } diff --git a/pi/audio_server.py b/pi/audio_server.py index 43265b6..e41d226 100644 --- a/pi/audio_server.py +++ b/pi/audio_server.py @@ -19,6 +19,7 @@ from pathlib import Path from random import randint import websockets +from PIL import Image logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("audio_server") @@ -26,6 +27,8 @@ log = logging.getLogger("audio_server") PORT = 8766 CHUNK_SIZE = 4096 AUDIO_DIR = Path(__file__).parent / "assets" / "alarm" +IMG_DIR = Path(__file__).parent / "assets" / "img" +STATUS_IMG_SIZE = 120 def find_wav() -> Path: @@ -49,6 +52,34 @@ def read_wav(path: Path) -> tuple[bytes, int, int, int]: return pcm, sr, ch, bits +def load_status_image(path: Path) -> bytes: + """Load a PNG, convert to 1-bit 120x120 monochrome bitmap (MSB-first, black=1).""" + img = Image.open(path).convert("L") + + # Resize to fit within 120x120, preserving aspect ratio + img.thumbnail((STATUS_IMG_SIZE, STATUS_IMG_SIZE), Image.LANCZOS) + + # Paste centered onto white canvas + canvas = Image.new("L", (STATUS_IMG_SIZE, STATUS_IMG_SIZE), 255) + x_off = (STATUS_IMG_SIZE - img.width) // 2 + y_off = (STATUS_IMG_SIZE - img.height) // 2 + canvas.paste(img, (x_off, y_off)) + + # Threshold to 1-bit: black (< 128) → 1, white → 0 + bw = canvas.point(lambda p: 1 if p < 128 else 0, "1") + raw = bw.tobytes() + log.info("Status image loaded: %s → %d bytes", path.name, len(raw)) + return raw + + +async def send_status_image(ws, img_bytes: bytes): + """Send a status image over the WebSocket (text header + binary payload).""" + header = json.dumps({"type": "status_image", "width": STATUS_IMG_SIZE, "height": STATUS_IMG_SIZE}) + await ws.send(header) + await ws.send(img_bytes) + log.info("Sent status image (%d bytes)", len(img_bytes)) + + def chunk_bytes(data: bytes, size: int): """Yield data in fixed-size chunks.""" for i in range(0, len(data), size): @@ -92,12 +123,24 @@ async def handler(ws): wav_path = find_wav() pcm, sr, ch, bits = read_wav(wav_path) + # Load status images + img_idle = load_status_image(IMG_DIR / "idle.png") + img_alarm = load_status_image(IMG_DIR / "on_alarm.png") + try: + # Send idle image on connect + await send_status_image(ws, img_idle) + while True: delay = randint(30, 60) log.info("Next alarm in %ds", delay) await asyncio.sleep(delay) + + # 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) except websockets.exceptions.ConnectionClosed: log.info("Client disconnected: %s:%d", remote[0], remote[1]) diff --git a/pi/requirements.txt b/pi/requirements.txt index 0a6cc9e..ec809d2 100644 --- a/pi/requirements.txt +++ b/pi/requirements.txt @@ -1,2 +1,3 @@ websockets>=12.0 psutil>=5.9.0 +Pillow>=10.0