Compare commits

..

2 Commits

Author SHA1 Message Date
Mikkeli Matlock
33936650c6 image alarm 2026-02-15 21:46:18 +09:00
Mikkeli Matlock
7eb05ea983 audio_client.cpp fix 2026-02-15 21:36:56 +09:00
9 changed files with 146 additions and 16 deletions

View File

@@ -5,7 +5,9 @@
"Bash(idf.py build:*)", "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(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(do sed -i '/\\\\.static_bitmap = 0,/d' \"$f\")",
"Bash(done)" "Bash(done)",
"Bash(file:*)",
"mcp__ide__getDiagnostics"
] ]
}, },
"outputStyle": "iseri", "outputStyle": "iseri",

View File

@@ -1,5 +1,5 @@
idf_component_register( idf_component_register(
SRCS "audio_client.cpp" 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 PRIV_REQUIRES esp_event json
INCLUDE_DIRS "./") INCLUDE_DIRS "./")

View File

@@ -18,6 +18,11 @@ static const char *TAG = "audio_client";
#define PLAYBACK_PRIORITY 4 #define PLAYBACK_PRIORITY 4
#define WS_BUFFER_SIZE 8192 #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 esp_websocket_client_handle_t s_client = NULL;
static CodecPort *s_codec = NULL; static CodecPort *s_codec = NULL;
static QueueHandle_t s_pcm_queue = 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 audio_state_t s_state = AUDIO_IDLE;
static volatile bool s_playing = false; 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 */ /* Forward declarations */
static void playback_task(void *arg); static void playback_task(void *arg);
static void ws_event_handler(void *arg, esp_event_base_t event_base, 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; 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) { if (strcmp(type->valuestring, "alarm_start") == 0) {
int sr = 24000; int sr = 24000;
int ch = 2; int ch = 2;
@@ -98,9 +116,22 @@ static void handle_text_frame(const char *data, int len)
static void handle_binary_frame(const uint8_t *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; 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) { if (!chunk) {
ESP_LOGW(TAG, "PSRAM alloc failed (%d bytes)", len); ESP_LOGW(TAG, "PSRAM alloc failed (%d bytes)", len);
return; return;
@@ -172,6 +203,14 @@ void audio_client_init(const char *uri, void *codec)
{ {
s_codec = (CodecPort *)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 *)); s_pcm_queue = xQueueCreate(PCM_QUEUE_DEPTH, sizeof(uint8_t *));
if (!s_pcm_queue) { if (!s_pcm_queue) {
ESP_LOGE(TAG, "Failed to create 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; 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;
}

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <stdbool.h> #include <stdbool.h>
#include "lvgl.h"
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
@@ -29,6 +30,13 @@ void audio_client_stop(void);
/** Get current audio client state. */ /** Get current audio client state. */
audio_state_t audio_client_get_state(void); 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 #ifdef __cplusplus
} }
#endif #endif

View File

@@ -39,11 +39,13 @@ static lv_obj_t *lbl_cpu_temp;
static lv_obj_t *tbl_services; static lv_obj_t *tbl_services;
static int s_service_count; static int s_service_count;
/* Local sensors */ /* Local sensors (bottom bar) */
static lv_obj_t *lbl_room_temp; static lv_obj_t *lbl_local;
static lv_obj_t *lbl_room_humi;
static lv_obj_t *lbl_uptime; static lv_obj_t *lbl_uptime;
/* Status image placeholder */
static lv_obj_t *img_status;
/* Network */ /* Network */
static lv_obj_t *lbl_net; static lv_obj_t *lbl_net;
@@ -267,11 +269,10 @@ static void create_main_section(lv_obj_t *parent)
ry += row_h; ry += row_h;
lbl_uptime = create_label(parent, rx + 4, ry, &InziuIosevka_Slab_CC_12px, "Uptime: --h"); lbl_uptime = create_label(parent, rx + 4, ry, &InziuIosevka_Slab_CC_12px, "Uptime: --h");
/* === Local Sensors (below pi vitals) === */ /* === Status image (120x120, bottom-right above bot bar) === */
int sy = ry + row_h + 4; img_status = lv_img_create(parent);
create_label(parent, rx + 4, sy, &InziuIosevka_Slab_CC_12px, "LOCAL SENSORS"); lv_obj_set_pos(img_status, 280, 156);
lbl_room_temp = create_label(parent, rx + 4, sy + 16, &InziuIosevka_Slab_CC_16px, "Room: --.-C"); lv_obj_set_size(img_status, 120, 120);
lbl_room_humi = create_label(parent, rx + 4, sy + 42, &InziuIosevka_Slab_CC_16px, "Humi: --%");
} }
static void create_bottom_bar(lv_obj_t *parent) 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_set_style_text_color(lbl_net, lv_color_black(), 0);
lv_obj_align(lbl_net, LV_ALIGN_LEFT_MID, 0, 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 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) void dashboard_ui_create(void)
@@ -365,11 +373,8 @@ void dashboard_ui_update_local(float temp, float humidity, uint8_t battery)
{ {
char buf[32]; char buf[32];
snprintf(buf, sizeof(buf), "Room: %.1fC", temp); snprintf(buf, sizeof(buf), "T: %.1f H: %.0f%%", temp, humidity);
lv_label_set_text(lbl_room_temp, buf); 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 text + bar */ /* Update top bar battery text + bar */
snprintf(buf, sizeof(buf), "%d%%", battery); 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); 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);
}
}

View File

@@ -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); 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 #ifdef __cplusplus
} }
#endif #endif

View File

@@ -211,6 +211,14 @@ static void sensor_task(void *arg)
Lvgl_unlock(); 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)); vTaskDelay(pdMS_TO_TICKS(1000));
} }
} }

View File

@@ -19,6 +19,7 @@ from pathlib import Path
from random import randint from random import randint
import websockets import websockets
from PIL import Image
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("audio_server") log = logging.getLogger("audio_server")
@@ -26,6 +27,8 @@ log = logging.getLogger("audio_server")
PORT = 8766 PORT = 8766
CHUNK_SIZE = 4096 CHUNK_SIZE = 4096
AUDIO_DIR = Path(__file__).parent / "assets" / "alarm" AUDIO_DIR = Path(__file__).parent / "assets" / "alarm"
IMG_DIR = Path(__file__).parent / "assets" / "img"
STATUS_IMG_SIZE = 120
def find_wav() -> Path: def find_wav() -> Path:
@@ -49,6 +52,34 @@ def read_wav(path: Path) -> tuple[bytes, int, int, int]:
return pcm, sr, ch, bits 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): def chunk_bytes(data: bytes, size: int):
"""Yield data in fixed-size chunks.""" """Yield data in fixed-size chunks."""
for i in range(0, len(data), size): for i in range(0, len(data), size):
@@ -92,12 +123,24 @@ async def handler(ws):
wav_path = find_wav() wav_path = find_wav()
pcm, sr, ch, bits = read_wav(wav_path) 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: try:
# Send idle image on connect
await send_status_image(ws, img_idle)
while True: while True:
delay = randint(30, 60) delay = randint(30, 60)
log.info("Next alarm in %ds", delay) log.info("Next alarm in %ds", delay)
await asyncio.sleep(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) 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: except websockets.exceptions.ConnectionClosed:
log.info("Client disconnected: %s:%d", remote[0], remote[1]) log.info("Client disconnected: %s:%d", remote[0], remote[1])

View File

@@ -1,2 +1,3 @@
websockets>=12.0 websockets>=12.0
psutil>=5.9.0 psutil>=5.9.0
Pillow>=10.0