Compare commits
2 Commits
dca989a01b
...
33936650c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33936650c6 | ||
|
|
7eb05ea983 |
@@ -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",
|
||||||
|
|||||||
@@ -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 "./")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
psutil>=5.9.0
|
psutil>=5.9.0
|
||||||
|
Pillow>=10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user