diff --git a/components/buddy_audio/buddy_audio.cpp b/components/buddy_audio/buddy_audio.cpp index 94643d6..8130964 100644 --- a/components/buddy_audio/buddy_audio.cpp +++ b/components/buddy_audio/buddy_audio.cpp @@ -8,8 +8,8 @@ Initialization Controller::initialize() noexcept { Initialization result; result.settingsError = settings::loadMuted(muted_); - result.speakerError = platform::initializeSpeaker(); - speakerReady_ = result.speakerError == ESP_OK; + /* The codec is opened only for the short duration of a cue. */ + speakerReady_ = true; return result; } @@ -22,7 +22,10 @@ esp_err_t Controller::setMuted(bool muted) noexcept esp_err_t Controller::play(platform::Sound sound) noexcept { if (muted_ || !speakerReady_) return ESP_OK; - return platform::playSound(sound); + esp_err_t error = platform::initializeSpeaker(); + if (error == ESP_OK) error = platform::playSound(sound); + const esp_err_t shutdownError = platform::shutdownSpeaker(); + return error == ESP_OK ? shutdownError : error; } } // namespace buddy::audio diff --git a/components/buddy_layout/include/buddy_glyph_font.hpp b/components/buddy_layout/include/buddy_glyph_font.hpp new file mode 100644 index 0000000..6ebe233 --- /dev/null +++ b/components/buddy_layout/include/buddy_glyph_font.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +namespace buddy::layout { + +/* Returns the five-column 5x7 bitmap for a supported character, or a blank + glyph for anything unsupported. Shared by the non-LVGL fallback text + renderers in buddy_ui and codex_ui (used on hosts without BUDDY_USE_LVGL_FONT, + such as the host test/preview build). */ +[[nodiscard]] inline const std::uint8_t *glyphBitmap(char value) noexcept +{ + static const std::uint8_t blank[5] = {0}; + static const std::uint8_t digits[10][5] = { + {0x3e,0x51,0x49,0x45,0x3e},{0x00,0x42,0x7f,0x40,0x00}, + {0x42,0x61,0x51,0x49,0x46},{0x21,0x41,0x45,0x4b,0x31}, + {0x18,0x14,0x12,0x7f,0x10},{0x27,0x45,0x45,0x45,0x39}, + {0x3c,0x4a,0x49,0x49,0x30},{0x01,0x71,0x09,0x05,0x03}, + {0x36,0x49,0x49,0x49,0x36},{0x06,0x49,0x49,0x29,0x1e}, + }; + static const std::uint8_t letters[26][5] = { + {0x7e,0x11,0x11,0x11,0x7e},{0x7f,0x49,0x49,0x49,0x36}, + {0x3e,0x41,0x41,0x41,0x22},{0x7f,0x41,0x41,0x22,0x1c}, + {0x7f,0x49,0x49,0x49,0x41},{0x7f,0x09,0x09,0x09,0x01}, + {0x3e,0x41,0x49,0x49,0x7a},{0x7f,0x08,0x08,0x08,0x7f}, + {0x00,0x41,0x7f,0x41,0x00},{0x20,0x40,0x41,0x3f,0x01}, + {0x7f,0x08,0x14,0x22,0x41},{0x7f,0x40,0x40,0x40,0x40}, + {0x7f,0x02,0x0c,0x02,0x7f},{0x7f,0x04,0x08,0x10,0x7f}, + {0x3e,0x41,0x41,0x41,0x3e},{0x7f,0x09,0x09,0x09,0x06}, + {0x3e,0x41,0x51,0x21,0x5e},{0x7f,0x09,0x19,0x29,0x46}, + {0x46,0x49,0x49,0x49,0x31},{0x01,0x01,0x7f,0x01,0x01}, + {0x3f,0x40,0x40,0x40,0x3f},{0x1f,0x20,0x40,0x20,0x1f}, + {0x3f,0x40,0x38,0x40,0x3f},{0x63,0x14,0x08,0x14,0x63}, + {0x07,0x08,0x70,0x08,0x07},{0x61,0x51,0x49,0x45,0x43}, + }; + static const std::uint8_t dash[5] = {0x08,0x08,0x08,0x08,0x08}; + static const std::uint8_t slash[5] = {0x20,0x10,0x08,0x04,0x02}; + static const std::uint8_t colon[5] = {0x00,0x36,0x36,0x00,0x00}; + static const std::uint8_t percent[5] = {0x63,0x13,0x08,0x64,0x63}; + static const std::uint8_t dot[5] = {0x00,0x60,0x60,0x00,0x00}; + static const std::uint8_t exclamation[5] = {0x00,0x00,0x5f,0x00,0x00}; + if (value >= '0' && value <= '9') return digits[value - '0']; + value = static_cast(toupper(static_cast(value))); + if (value >= 'A' && value <= 'Z') return letters[value - 'A']; + if (value == '-') return dash; + if (value == '/') return slash; + if (value == ':') return colon; + if (value == '%') return percent; + if (value == '.') return dot; + if (value == '!') return exclamation; + return blank; +} + +} // namespace buddy::layout diff --git a/components/buddy_layout/include/buddy_layout.hpp b/components/buddy_layout/include/buddy_layout.hpp index 76d3e28..5778303 100644 --- a/components/buddy_layout/include/buddy_layout.hpp +++ b/components/buddy_layout/include/buddy_layout.hpp @@ -31,4 +31,32 @@ inline constexpr Rect Mute{40, 112, 240, 38}; inline constexpr Rect SwitchMode{40, 156, 240, 40}; } // namespace claude_info +namespace claude_approval { +inline constexpr Rect Approve{12, 143, 143, 55}; +inline constexpr Rect Deny{165, 143, 143, 55}; +} // namespace claude_approval + +/* Shared geometry for the Codex 3x2 command/agent button grid: both the + Control and Agents pages, and their hit-testers, index into this grid. */ +namespace codex_grid { +inline constexpr std::uint16_t OriginX = 5; +inline constexpr std::uint16_t OriginY = 38; +inline constexpr std::uint16_t CellWidth = 100; +inline constexpr std::uint16_t CellHeight = 76; +inline constexpr std::uint16_t ColumnStride = 105; +inline constexpr std::uint16_t RowStride = 84; +inline constexpr unsigned Columns = 3; +inline constexpr unsigned Rows = 2; + +[[nodiscard]] constexpr Rect cell(unsigned index) noexcept +{ + const unsigned column = index % Columns; + const unsigned row = index / Columns; + return Rect{ + static_cast(OriginX + column * ColumnStride), + static_cast(OriginY + row * RowStride), CellWidth, + CellHeight}; +} +} // namespace codex_grid + } // namespace buddy::layout diff --git a/components/buddy_ui/buddy_ui.cpp b/components/buddy_ui/buddy_ui.cpp index 9f2362f..90c2186 100644 --- a/components/buddy_ui/buddy_ui.cpp +++ b/components/buddy_ui/buddy_ui.cpp @@ -1,6 +1,5 @@ #include "buddy_ui.hpp" -#include #include #include #include @@ -9,6 +8,8 @@ #ifdef BUDDY_USE_LVGL_FONT #include "ui_font.hpp" +#else +#include "buddy_glyph_font.hpp" #endif namespace buddy::display { @@ -120,53 +121,6 @@ static void card(Canvas *canvas, int x, int y, int width, int height, rounded(canvas, x + 18, y + 1, width - 36, 4, 2, accent); } -#ifndef BUDDY_USE_LVGL_FONT -/* Returns the five-column bitmap for a supported character or fallback glyph. */ -static const uint8_t *glyph(char value) -{ - static const uint8_t blank[5] = {0}; - static const uint8_t digits[10][5] = { - {0x3e,0x51,0x49,0x45,0x3e},{0x00,0x42,0x7f,0x40,0x00}, - {0x42,0x61,0x51,0x49,0x46},{0x21,0x41,0x45,0x4b,0x31}, - {0x18,0x14,0x12,0x7f,0x10},{0x27,0x45,0x45,0x45,0x39}, - {0x3c,0x4a,0x49,0x49,0x30},{0x01,0x71,0x09,0x05,0x03}, - {0x36,0x49,0x49,0x49,0x36},{0x06,0x49,0x49,0x29,0x1e}, - }; - static const uint8_t letters[26][5] = { - {0x7e,0x11,0x11,0x11,0x7e},{0x7f,0x49,0x49,0x49,0x36}, - {0x3e,0x41,0x41,0x41,0x22},{0x7f,0x41,0x41,0x22,0x1c}, - {0x7f,0x49,0x49,0x49,0x41},{0x7f,0x09,0x09,0x09,0x01}, - {0x3e,0x41,0x49,0x49,0x7a},{0x7f,0x08,0x08,0x08,0x7f}, - {0x00,0x41,0x7f,0x41,0x00},{0x20,0x40,0x41,0x3f,0x01}, - {0x7f,0x08,0x14,0x22,0x41},{0x7f,0x40,0x40,0x40,0x40}, - {0x7f,0x02,0x0c,0x02,0x7f},{0x7f,0x04,0x08,0x10,0x7f}, - {0x3e,0x41,0x41,0x41,0x3e},{0x7f,0x09,0x09,0x09,0x06}, - {0x3e,0x41,0x51,0x21,0x5e},{0x7f,0x09,0x19,0x29,0x46}, - {0x46,0x49,0x49,0x49,0x31},{0x01,0x01,0x7f,0x01,0x01}, - {0x3f,0x40,0x40,0x40,0x3f},{0x1f,0x20,0x40,0x20,0x1f}, - {0x3f,0x40,0x38,0x40,0x3f},{0x63,0x14,0x08,0x14,0x63}, - {0x07,0x08,0x70,0x08,0x07},{0x61,0x51,0x49,0x45,0x43}, - }; - static const uint8_t dash[5] = {0x08,0x08,0x08,0x08,0x08}; - static const uint8_t slash[5] = {0x20,0x10,0x08,0x04,0x02}; - static const uint8_t colon[5] = {0x00,0x36,0x36,0x00,0x00}; - static const uint8_t percent[5] = {0x63,0x13,0x08,0x64,0x63}; - static const uint8_t dot[5] = {0x00,0x60,0x60,0x00,0x00}; - static const uint8_t exclamation[5] = {0x00,0x00,0x5f,0x00,0x00}; - if (value >= '0' && value <= '9') return digits[value - '0']; - value = static_cast( - toupper(static_cast(value))); - if (value >= 'A' && value <= 'Z') return letters[value - 'A']; - if (value == '-') return dash; - if (value == '/') return slash; - if (value == ':') return colon; - if (value == '%') return percent; - if (value == '.') return dot; - if (value == '!') return exclamation; - return blank; -} -#endif - /* Calculates pixel width for fixed 5x7 glyphs plus spacing. */ static int text_width(const char *text, int scale) { @@ -188,7 +142,7 @@ static void text(Canvas *canvas, const char *value, int x, int y, int scale, x, y, scale, color); #else for (; *value != '\0'; ++value, x += 6 * scale) { - const uint8_t *columns = glyph(*value); + const uint8_t *columns = layout::glyphBitmap(*value); for (int column = 0; column < 5; ++column) for (int row = 0; row < 7; ++row) if ((columns[column] & (1U << row)) != 0) @@ -531,9 +485,12 @@ static void draw_approval(Canvas *canvas, const buddy::claude::Model *model) centered(canvas, model->promptTool[0] ? model->promptTool.data() : "TOOL", 160, 70, 2, Text); clipped_text(canvas, model->promptHint.data(), 14, 102, 48, Muted); - box(canvas, 12, 143, 143, 55, Green, Panel); + const layout::Rect approve = layout::claude_approval::Approve; + const layout::Rect deny = layout::claude_approval::Deny; + box(canvas, approve.x, approve.y, approve.width, approve.height, Green, + Panel); centered(canvas, "APPROVE", 83, 160, 2, Text); - box(canvas, 165, 143, 143, 55, Red, Panel); + box(canvas, deny.x, deny.y, deny.width, deny.height, Red, Panel); centered(canvas, "DENY", 236, 160, 2, Text); } @@ -575,8 +532,10 @@ ClaudeAction claudeHit(const claude::Model &model, std::uint16_t x, std::uint16_t y) noexcept { if (model.promptActive) { - if (in_rect(x, y, 12, 143, 143, 55)) return ClaudeAction::Approve; - if (in_rect(x, y, 165, 143, 143, 55)) return ClaudeAction::Deny; + if (layout::contains(layout::claude_approval::Approve, x, y)) + return ClaudeAction::Approve; + if (layout::contains(layout::claude_approval::Deny, x, y)) + return ClaudeAction::Deny; return ClaudeAction::None; } if (y >= TabTop) { diff --git a/components/claude_ble_transport/claude_ble_transport.cpp b/components/claude_ble_transport/claude_ble_transport.cpp index 3e9c179..9bb104a 100644 --- a/components/claude_ble_transport/claude_ble_transport.cpp +++ b/components/claude_ble_transport/claude_ble_transport.cpp @@ -255,6 +255,20 @@ static void gatts_callback(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, s_peer_known = true; memcpy(s_peer_address, parameters->connect.remote_bda, sizeof(s_peer_address)); + { + esp_ble_conn_update_params_t connection{}; + memcpy(connection.bda, parameters->connect.remote_bda, + sizeof(connection.bda)); + connection.min_int = 0x18; + connection.max_int = 0x28; + connection.latency = 0; + connection.timeout = 400; + const esp_err_t interval_error = + esp_ble_gap_update_conn_params(&connection); + if (interval_error != ESP_OK) + ESP_LOGW(TAG, "Connection interval request failed: %s", + esp_err_to_name(interval_error)); + } notify_connection(true); esp_ble_set_encryption(parameters->connect.remote_bda, ESP_BLE_SEC_ENCRYPT_MITM); diff --git a/components/claude_protocol/claude_protocol.cpp b/components/claude_protocol/claude_protocol.cpp index 834cdfa..d6874e0 100644 --- a/components/claude_protocol/claude_protocol.cpp +++ b/components/claude_protocol/claude_protocol.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include "fixed_json.hpp" @@ -17,45 +16,15 @@ using fixed_json::Token; using fixed_json::TokenType; using fixed_json::objectValue; using fixed_json::tokenEquals; +using fixed_json::tokenI64; using fixed_json::tokenize; +using fixed_json::tokenStringCopy; +using fixed_json::tokenU64; /* Claude messages are processed on the single application task. Keeping the fixed token table here avoids consuming half of that task's stack. */ Parser parserStorage; -/* Parses an unsigned integer token with full-token and overflow validation. */ -bool tokenU64(std::string_view json, const Token &token, std::uint64_t &value) noexcept -{ - if (token.type != TokenType::Primitive) return false; - const auto length = static_cast(token.end - token.start); - if (length == 0 || length >= 32 || json[static_cast(token.start)] == '-') - return false; - std::array buffer{}; - json.copy(buffer.data(), length, static_cast(token.start)); - buffer[length] = '\0'; - char *end = nullptr; - const unsigned long long parsed = std::strtoull(buffer.data(), &end, 10); - if (end != buffer.data() + length) return false; - value = static_cast(parsed); - return true; -} - -/* Parses a signed integer token with full-token and range validation. */ -bool tokenI64(std::string_view json, const Token &token, std::int64_t &value) noexcept -{ - if (token.type != TokenType::Primitive) return false; - const auto length = static_cast(token.end - token.start); - if (length == 0 || length >= 32) return false; - std::array buffer{}; - json.copy(buffer.data(), length, static_cast(token.start)); - buffer[length] = '\0'; - char *end = nullptr; - const long long parsed = std::strtoll(buffer.data(), &end, 10); - if (end != buffer.data() + length) return false; - value = static_cast(parsed); - return true; -} - /* Finds an error flag at any depth in a turn event's raw SDK content. */ bool hasErrorFlag(std::string_view json, const Parser &parser) noexcept { @@ -73,47 +42,6 @@ bool hasErrorFlag(std::string_view json, const Parser &parser) noexcept return false; } -/* Appends U+FFFD's placeholder when an escaped Unicode sequence cannot be represented. */ -void appendUtf8Replacement(std::span destination, std::size_t &written) noexcept -{ - if (written + 1 < destination.size()) destination[written++] = '?'; -} - -/* Copies and minimally unescapes a JSON string into a bounded model buffer. */ -void copyString(std::string_view json, const Token &token, std::span destination) noexcept -{ - if (destination.empty()) return; - destination[0] = '\0'; - if (token.type != TokenType::String) return; - std::size_t written = 0; - for (int position = token.start; position < token.end && - written + 1 < destination.size(); ++position) { - char byte = json[static_cast(position)]; - if (byte != '\\') { - destination[written++] = byte; - continue; - } - if (++position >= token.end) break; - switch (json[static_cast(position)]) { - case '"': byte = '"'; break; - case '\\': byte = '\\'; break; - case '/': byte = '/'; break; - case 'b': byte = '\b'; break; - case 'f': byte = '\f'; break; - case 'n': byte = ' '; break; - case 'r': byte = ' '; break; - case 't': byte = ' '; break; - case 'u': - position += 4; - appendUtf8Replacement(destination, written); - continue; - default: continue; - } - destination[written++] = byte; - } - destination[written] = '\0'; -} - /* Reads an optional named unsigned field and narrows it safely to uint32_t. */ bool copyU32Field(std::string_view json, const Parser &parser, std::string_view key, std::uint32_t &destination) noexcept @@ -181,7 +109,7 @@ bool handleSnapshot(std::string_view json, const Parser &parser, Model &model, const int message = objectValue(json, parser, 0, "msg"); if (message >= 0) - copyString(json, parser.tokens[static_cast(message)], model.message); + tokenStringCopy(json, parser.tokens[static_cast(message)], model.message); model.entries.fill({}); const int entries = objectValue(json, parser, 0, "entries"); @@ -192,7 +120,7 @@ bool handleSnapshot(std::string_view json, const Parser &parser, Model &model, index < parser.count && entryIndex < ModelEntryCount; ++index) { if (parser.tokens[index].parent == entries && parser.tokens[index].type == TokenType::String) { - copyString(json, parser.tokens[index], model.entries[entryIndex]); + tokenStringCopy(json, parser.tokens[index], model.entries[entryIndex]); ++entryIndex; } } @@ -215,13 +143,13 @@ bool handleSnapshot(std::string_view json, const Parser &parser, Model &model, const int tool = objectValue(json, parser, prompt, "tool"); const int hint = objectValue(json, parser, prompt, "hint"); if (id >= 0 && parser.tokens[static_cast(id)].type == TokenType::String) { - copyString(json, parser.tokens[static_cast(id)], model.promptId); + tokenStringCopy(json, parser.tokens[static_cast(id)], model.promptId); model.promptActive = model.promptId[0] != '\0'; } if (tool >= 0) - copyString(json, parser.tokens[static_cast(tool)], model.promptTool); + tokenStringCopy(json, parser.tokens[static_cast(tool)], model.promptTool); if (hint >= 0) - copyString(json, parser.tokens[static_cast(hint)], model.promptHint); + tokenStringCopy(json, parser.tokens[static_cast(hint)], model.promptHint); } if (previousRunning > 0 && running == 0 && waiting == 0 && !model.promptActive) { model.transientState = PetState::Celebrate; @@ -284,7 +212,7 @@ bool commandResponse(std::string_view json, const Parser &parser, int command, const bool owner = tokenEquals(json, commandToken, "owner"); const int value = objectValue(json, parser, 0, "name"); if (value >= 0) { - copyString(json, parser.tokens[static_cast(value)], + tokenStringCopy(json, parser.tokens[static_cast(value)], owner ? std::span{model.owner} : std::span{model.deviceName}); action.modelChanged = true; action.persistModel = true; @@ -301,7 +229,7 @@ bool commandResponse(std::string_view json, const Parser &parser, int command, } std::array commandName{}; - copyString(json, commandToken, commandName); + tokenStringCopy(json, commandToken, commandName); return setResponse(action, std::snprintf(action.response.data(), action.response.size(), "{\"ack\":\"%s\",\"ok\":false,\"error\":\"unsupported\"}", commandName.data())); } diff --git a/components/codex_ble_transport/codex_ble_transport.cpp b/components/codex_ble_transport/codex_ble_transport.cpp index bcc4b6b..f28ea52 100644 --- a/components/codex_ble_transport/codex_ble_transport.cpp +++ b/components/codex_ble_transport/codex_ble_transport.cpp @@ -34,7 +34,7 @@ static unsigned s_advertising_ready; static esp_hid_raw_report_map_t s_report_maps[] = {{ .data = buddy::codex::HidReportMap.data(), - .len = 29, + .len = static_cast(buddy::codex::HidReportMap.size()), }}; static esp_hid_device_config_t s_hid_config = { @@ -155,8 +155,10 @@ static esp_err_t configure_gap(void) }; esp_ble_adv_data_t advertising{}; advertising.include_txpower = true; - advertising.min_interval = 0x0006; - advertising.max_interval = 0x0012; + /* Prefer 30-50 ms connections so an idle paired device does not wake the + radio at the former 7.5 ms rate. The host may still negotiate faster. */ + advertising.min_interval = 0x0018; + advertising.max_interval = 0x0028; advertising.appearance = ESP_HID_APPEARANCE_GENERIC; advertising.service_uuid_len = sizeof(hid_service_uuid); advertising.p_service_uuid = hid_service_uuid; @@ -197,8 +199,6 @@ esp_err_t initialize(Listener &listener) noexcept { if (s_hid_device != nullptr) return ESP_ERR_INVALID_STATE; s_listener = &listener; - s_report_maps[0].len = - static_cast(buddy::codex::HidReportMap.size()); esp_err_t error = esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); if (error != ESP_OK && error != ESP_ERR_INVALID_STATE) return error; diff --git a/components/codex_input/codex_input.cpp b/components/codex_input/codex_input.cpp index eac44b7..bbac906 100644 --- a/components/codex_input/codex_input.cpp +++ b/components/codex_input/codex_input.cpp @@ -33,16 +33,22 @@ bool inMenuTab(std::uint16_t x, std::uint16_t y) noexcept return inRect(x, y, 240, TabTop, 80, ScreenHeight - TabTop); } -/* Maps one cell in a regular button grid to the requested action type. */ +/* Maps one cell in the shared Codex button grid to the requested action type. */ Action gridAction(std::uint16_t x, std::uint16_t y, ActionType type) noexcept { - if (x < 5 || y < 38) return noAction(); - const unsigned column = (x - 5U) / 105U; - const unsigned row = (y - 38U) / 84U; - if (column >= 3 || row >= 2 || - !inRect(x, y, 5U + column * 105U, 38U + row * 84U, 100, 76)) + using layout::codex_grid::ColumnStride; + using layout::codex_grid::Columns; + using layout::codex_grid::OriginX; + using layout::codex_grid::OriginY; + using layout::codex_grid::Rows; + using layout::codex_grid::RowStride; + if (x < OriginX || y < OriginY) return noAction(); + const unsigned column = (x - OriginX) / ColumnStride; + const unsigned row = (y - OriginY) / RowStride; + if (column >= Columns || row >= Rows || + !layout::contains(layout::codex_grid::cell(row * Columns + column), x, y)) return noAction(); - const unsigned index = row * 3U + column; + const unsigned index = row * Columns + column; Action action{.type = type}; if (type == ActionType::Slot) { action.slot = static_cast(index); diff --git a/components/codex_rpc/codex_rpc.cpp b/components/codex_rpc/codex_rpc.cpp index f273698..e280530 100644 --- a/components/codex_rpc/codex_rpc.cpp +++ b/components/codex_rpc/codex_rpc.cpp @@ -2,7 +2,6 @@ #include #include -#include #include #include @@ -18,30 +17,32 @@ using fixed_json::TokenType; using fixed_json::objectValue; using fixed_json::tokenEquals; using fixed_json::tokenize; - -/* Copies and parses a numeric token as a bounded double value. */ -bool tokenNumber(std::string_view json, const Token &token, double &value) noexcept -{ - if (token.type != TokenType::Primitive) return false; - const auto length = static_cast(token.end - token.start); - if (length == 0 || length >= 40) return false; - std::array buffer{}; - json.copy(buffer.data(), length, static_cast(token.start)); - buffer[length] = '\0'; - char *end = nullptr; - value = std::strtod(buffer.data(), &end); - return end == buffer.data() + length && std::isfinite(value); -} - -/* Copies a string token into a bounded C buffer for model storage. */ -void tokenStringCopy(std::string_view json, const Token &token, - std::span destination) noexcept +using fixed_json::tokenNumber; +using fixed_json::tokenStringCopy; + +/* Merges the shared color/brightness/effect/effectSpeed fields present on both + Slot and Light from a partial desktop object into an existing light value. */ +template +void applyLightFields(std::string_view json, const Parser &parser, int object, + LightLike &light) noexcept { - if (token.type != TokenType::String || destination.empty()) return; - std::size_t length = static_cast(token.end - token.start); - if (length >= destination.size()) length = destination.size() - 1; - json.copy(destination.data(), length, static_cast(token.start)); - destination[length] = '\0'; + double number; + int token = objectValue(json, parser, object, "c"); + if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number) && + number >= 0 && number <= 16777215 && std::floor(number) == number) + light.color = static_cast(number); + token = objectValue(json, parser, object, "b"); + if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number)) { + light.brightness = static_cast(number); + if (light.brightness < 0) light.brightness = 0; + if (light.brightness > 1) light.brightness = 1; + } + token = objectValue(json, parser, object, "e"); + if (token >= 0) + tokenStringCopy(json, parser.tokens[static_cast(token)], light.effect); + token = objectValue(json, parser, object, "s"); + if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number)) + light.effectSpeed = static_cast(number); } /* Creates a neutral raw slot value before applying partial desktop fields. */ @@ -93,23 +94,7 @@ void updateSlot(std::string_view json, const Parser &parser, int object, result.eventCount >= MaxEvents) return; const auto index = static_cast(id); Slot slot = context.model != nullptr ? context.model->slots[index] : defaultSlot(); - double number; - int token = objectValue(json, parser, object, "c"); - if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number) && - number >= 0 && number <= 16777215 && std::floor(number) == number) - slot.color = static_cast(number); - token = objectValue(json, parser, object, "b"); - if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number)) { - slot.brightness = static_cast(number); - if (slot.brightness < 0) slot.brightness = 0; - if (slot.brightness > 1) slot.brightness = 1; - } - token = objectValue(json, parser, object, "e"); - if (token >= 0) - tokenStringCopy(json, parser.tokens[static_cast(token)], slot.effect); - token = objectValue(json, parser, object, "s"); - if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number)) - slot.effectSpeed = static_cast(number); + applyLightFields(json, parser, object, slot); slot.breathing = std::strcmp(slot.effect.data(), "breath") == 0; slot.status = classifySlot(slot); result.events[result.eventCount++] = @@ -122,23 +107,7 @@ void updateLight(std::string_view json, const Parser &parser, int object, { if (object < 0 || parser.tokens[static_cast(object)].type != TokenType::Object) return; - double number; - int token = objectValue(json, parser, object, "c"); - if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number) && - number >= 0 && number <= 16777215 && std::floor(number) == number) - light.color = static_cast(number); - token = objectValue(json, parser, object, "b"); - if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number)) { - light.brightness = static_cast(number); - if (light.brightness < 0) light.brightness = 0; - if (light.brightness > 1) light.brightness = 1; - } - token = objectValue(json, parser, object, "e"); - if (token >= 0) - tokenStringCopy(json, parser.tokens[static_cast(token)], light.effect); - token = objectValue(json, parser, object, "s"); - if (token >= 0 && tokenNumber(json, parser.tokens[static_cast(token)], number)) - light.effectSpeed = static_cast(number); + applyLightFields(json, parser, object, light); } /* Converts a full lighting configuration RPC payload into model events. */ diff --git a/components/codex_ui/codex_ui.cpp b/components/codex_ui/codex_ui.cpp index 44e6912..07c11a7 100644 --- a/components/codex_ui/codex_ui.cpp +++ b/components/codex_ui/codex_ui.cpp @@ -7,6 +7,8 @@ #ifdef BUDDY_USE_LVGL_FONT #include "ui_font.hpp" +#else +#include "buddy_glyph_font.hpp" #endif namespace buddy::codex::ui { @@ -94,43 +96,6 @@ static void rounded_box(Canvas *canvas, int x, int y, int width, int height, radius - thickness, background); } -#ifndef BUDDY_USE_LVGL_FONT -/* Returns the five-column bitmap for a supported character or fallback glyph. */ -static const uint8_t *glyph(char value) -{ - static const uint8_t blank[5] = {0}; - static const uint8_t digits[10][5] = { - {0x3e,0x51,0x49,0x45,0x3e},{0x00,0x42,0x7f,0x40,0x00}, - {0x42,0x61,0x51,0x49,0x46},{0x21,0x41,0x45,0x4b,0x31}, - {0x18,0x14,0x12,0x7f,0x10},{0x27,0x45,0x45,0x45,0x39}, - {0x3c,0x4a,0x49,0x49,0x30},{0x01,0x71,0x09,0x05,0x03}, - {0x36,0x49,0x49,0x49,0x36},{0x06,0x49,0x49,0x29,0x1e}, - }; - static const uint8_t letters[26][5] = { - {0x7e,0x11,0x11,0x11,0x7e},{0x7f,0x49,0x49,0x49,0x36}, - {0x3e,0x41,0x41,0x41,0x22},{0x7f,0x41,0x41,0x22,0x1c}, - {0x7f,0x49,0x49,0x49,0x41},{0x7f,0x09,0x09,0x09,0x01}, - {0x3e,0x41,0x49,0x49,0x7a},{0x7f,0x08,0x08,0x08,0x7f}, - {0x00,0x41,0x7f,0x41,0x00},{0x20,0x40,0x41,0x3f,0x01}, - {0x7f,0x08,0x14,0x22,0x41},{0x7f,0x40,0x40,0x40,0x40}, - {0x7f,0x02,0x0c,0x02,0x7f},{0x7f,0x04,0x08,0x10,0x7f}, - {0x3e,0x41,0x41,0x41,0x3e},{0x7f,0x09,0x09,0x09,0x06}, - {0x3e,0x41,0x51,0x21,0x5e},{0x7f,0x09,0x19,0x29,0x46}, - {0x46,0x49,0x49,0x49,0x31},{0x01,0x01,0x7f,0x01,0x01}, - {0x3f,0x40,0x40,0x40,0x3f},{0x1f,0x20,0x40,0x20,0x1f}, - {0x3f,0x40,0x38,0x40,0x3f},{0x63,0x14,0x08,0x14,0x63}, - {0x07,0x08,0x70,0x08,0x07},{0x61,0x51,0x49,0x45,0x43}, - }; - static const uint8_t dash[5] = {0x08,0x08,0x08,0x08,0x08}; - static const uint8_t slash[5] = {0x20,0x10,0x08,0x04,0x02}; - if (value >= '0' && value <= '9') return digits[value - '0']; - if (value >= 'A' && value <= 'Z') return letters[value - 'A']; - if (value == '-') return dash; - if (value == '/') return slash; - return blank; -} -#endif - /* Calculates pixel width for fixed 5x7 glyphs plus one-column spacing. */ static int text_width(const char *text, int scale) { @@ -152,7 +117,7 @@ static void draw_text(Canvas *canvas, const char *text, int x, int y, x, y, scale, color); #else for (; *text != '\0'; ++text, x += 6 * scale) { - const uint8_t *columns = glyph(*text); + const uint8_t *columns = layout::glyphBitmap(*text); for (int column = 0; column < 5; ++column) for (int row = 0; row < 7; ++row) if ((columns[column] & (1U << row)) != 0) @@ -364,9 +329,11 @@ static void draw_control(Canvas *canvas, const Model *model, return; } for (int index = 0; index < 6; ++index) { - const int x = 5 + (index % 3) * 105; - const int y = 38 + (index / 3) * 84; - button(canvas, x, y, 100, 76, labels[index], index == 4 ? "HOLD" : "TAP", + const layout::Rect cell = layout::codex_grid::cell(index); + const int x = cell.x; + const int y = cell.y; + button(canvas, x, y, cell.width, cell.height, labels[index], + index == 4 ? "HOLD" : "TAP", borders[index], backgrounds[index], action_matches(active, ActionType::Key, buddy::codex::controlKey(index)), @@ -379,8 +346,9 @@ static void draw_agents(Canvas *canvas, const Model *model, const Action *active, uint32_t time_ms) { for (int index = 0; index < static_cast(SlotCount); ++index) { - const int x = 5 + (index % 3) * 105; - const int y = 38 + (index / 3) * 84; + const layout::Rect cell = layout::codex_grid::cell(index); + const int x = cell.x; + const int y = cell.y; char label[10]; char number[3]; snprintf(label, sizeof(label), "AGENT %d", index + 1); diff --git a/components/fixed_json/include/fixed_json.hpp b/components/fixed_json/include/fixed_json.hpp index efc0803..77bf4fe 100644 --- a/components/fixed_json/include/fixed_json.hpp +++ b/components/fixed_json/include/fixed_json.hpp @@ -2,8 +2,11 @@ #include #include +#include #include #include +#include +#include #include namespace buddy::fixed_json { @@ -149,6 +152,100 @@ inline bool tokenEquals(std::string_view json, const Token &token, json.substr(static_cast(token.start), length) == text; } +/* Copies a Primitive token's raw text into a bounded stack buffer, ready for + strtoX-style parsing. Shared by the unsigned/signed/floating-point token + parsers below. */ +template +bool copyPrimitiveToken(std::string_view json, const Token &token, + std::array &buffer, + std::size_t &length) noexcept +{ + if (token.type != TokenType::Primitive) return false; + length = static_cast(token.end - token.start); + if (length == 0 || length >= BufferSize) return false; + json.copy(buffer.data(), length, static_cast(token.start)); + buffer[length] = '\0'; + return true; +} + +/* Parses an unsigned integer token with full-token and overflow validation. */ +inline bool tokenU64(std::string_view json, const Token &token, + std::uint64_t &value) noexcept +{ + std::array buffer{}; + std::size_t length = 0; + if (!copyPrimitiveToken(json, token, buffer, length) || buffer[0] == '-') + return false; + char *end = nullptr; + const unsigned long long parsed = std::strtoull(buffer.data(), &end, 10); + if (end != buffer.data() + length) return false; + value = static_cast(parsed); + return true; +} + +/* Parses a signed integer token with full-token validation. */ +inline bool tokenI64(std::string_view json, const Token &token, + std::int64_t &value) noexcept +{ + std::array buffer{}; + std::size_t length = 0; + if (!copyPrimitiveToken(json, token, buffer, length)) return false; + char *end = nullptr; + const long long parsed = std::strtoll(buffer.data(), &end, 10); + if (end != buffer.data() + length) return false; + value = static_cast(parsed); + return true; +} + +/* Parses a finite floating-point token with full-token validation. */ +inline bool tokenNumber(std::string_view json, const Token &token, + double &value) noexcept +{ + std::array buffer{}; + std::size_t length = 0; + if (!copyPrimitiveToken(json, token, buffer, length)) return false; + char *end = nullptr; + value = std::strtod(buffer.data(), &end); + return end == buffer.data() + length && std::isfinite(value); +} + +/* Copies and minimally unescapes a JSON string token into a bounded buffer, + replacing unsupported \u escapes with '?'. */ +inline void tokenStringCopy(std::string_view json, const Token &token, + std::span destination) noexcept +{ + if (destination.empty()) return; + destination[0] = '\0'; + if (token.type != TokenType::String) return; + std::size_t written = 0; + for (int position = token.start; position < token.end && + written + 1 < destination.size(); ++position) { + char byte = json[static_cast(position)]; + if (byte != '\\') { + destination[written++] = byte; + continue; + } + if (++position >= token.end) break; + switch (json[static_cast(position)]) { + case '"': byte = '"'; break; + case '\\': byte = '\\'; break; + case '/': byte = '/'; break; + case 'b': byte = '\b'; break; + case 'f': byte = '\f'; break; + case 'n': byte = ' '; break; + case 'r': byte = ' '; break; + case 't': byte = ' '; break; + case 'u': + position += 4; + if (written + 1 < destination.size()) destination[written++] = '?'; + continue; + default: continue; + } + destination[written++] = byte; + } + destination[written] = '\0'; +} + template int objectValue(std::string_view json, const Parser &parser, int object, std::string_view key) noexcept diff --git a/components/platform_core_s3/include/platform_core_s3.hpp b/components/platform_core_s3/include/platform_core_s3.hpp index 24d8839..7eaa431 100644 --- a/components/platform_core_s3/include/platform_core_s3.hpp +++ b/components/platform_core_s3/include/platform_core_s3.hpp @@ -67,6 +67,9 @@ struct BatteryStatus { /* Initializes speaker hardware; failure is nonfatal to either runtime. */ [[nodiscard]] esp_err_t initializeSpeaker() noexcept; +/* Closes the audio path and holds the speaker amplifier in reset. */ +[[nodiscard]] esp_err_t shutdownSpeaker() noexcept; + /* Plays the fixed tone sequence associated with a semantic UI sound. */ [[nodiscard]] esp_err_t playSound(Sound sound) noexcept; diff --git a/components/platform_core_s3/platform_core_s3.cpp b/components/platform_core_s3/platform_core_s3.cpp index e997e82..4832fce 100644 --- a/components/platform_core_s3/platform_core_s3.cpp +++ b/components/platform_core_s3/platform_core_s3.cpp @@ -9,11 +9,13 @@ #include "esp_lcd_panel_ops.h" #include "esp_lcd_touch.h" #include "esp_log.h" +#include "esp_pm.h" #include "esp_codec_dev.h" #include "driver/i2c_master.h" #include "bmi270.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" +#include "freertos/task.h" namespace buddy::platform { @@ -29,9 +31,98 @@ static bool s_touch_active; static uint16_t s_touch_x; static uint16_t s_touch_y; static esp_codec_dev_handle_t s_speaker; +static bool s_speaker_open; static int16_t s_tone_samples[2646]; static i2c_master_dev_handle_t s_rtc; static bmi270_handle_t *s_imu; +static i2c_master_dev_handle_t s_pmu; +static i2c_master_dev_handle_t s_io_expander; +static esp_pm_lock_handle_t s_display_sleep_lock; +static bool s_display_sleep_lock_held; + +static constexpr uint8_t PmuLdoEnableRegister = 0x90; +static constexpr uint8_t PmuBacklightEnable = 0x80; +static constexpr uint8_t PmuBacklightVoltageRegister = 0x99; +static constexpr uint8_t IoExpanderPort0Register = 0x02; +static constexpr uint8_t IoExpanderSpeakerReset = 1U << 2; +static constexpr uint8_t ImuPowerControlRegister = 0x7d; +static constexpr uint8_t ImuAccelerometerEnable = 0x04; + +static esp_err_t read_register(i2c_master_dev_handle_t device, + uint8_t reg, uint8_t &value) noexcept +{ + return i2c_master_transmit_receive( + device, ®, sizeof(reg), &value, sizeof(value), 1000); +} + +static esp_err_t write_register(i2c_master_dev_handle_t device, + uint8_t reg, uint8_t value) noexcept +{ + const uint8_t data[] = {reg, value}; + return i2c_master_transmit(device, data, sizeof(data), 1000); +} + +static esp_err_t update_register(i2c_master_dev_handle_t device, + uint8_t reg, uint8_t mask, + bool enabled) noexcept +{ + uint8_t value = 0; + esp_err_t error = read_register(device, reg, value); + if (error != ESP_OK) return error; + value = enabled ? static_cast(value | mask) + : static_cast(value & ~mask); + return write_register(device, reg, value); +} + +static esp_err_t add_power_devices() noexcept +{ + i2c_master_bus_handle_t bus = nullptr; + esp_err_t error = i2c_master_get_bus_handle(BSP_I2C_NUM, &bus); + if (error != ESP_OK) return error; + i2c_device_config_t config{}; + config.dev_addr_length = I2C_ADDR_BIT_LEN_7; + config.scl_speed_hz = 400000; + config.device_address = 0x34; + error = i2c_master_bus_add_device(bus, &config, &s_pmu); + if (error != ESP_OK) return error; + config.device_address = 0x58; + return i2c_master_bus_add_device(bus, &config, &s_io_expander); +} + +static esp_err_t set_backlight(int brightness_percent) noexcept +{ + if (s_pmu == nullptr) return ESP_ERR_INVALID_STATE; + if (brightness_percent <= 0) { + esp_err_t error = update_register( + s_pmu, PmuLdoEnableRegister, PmuBacklightEnable, false); + if (error != ESP_OK) return error; + return write_register(s_pmu, PmuBacklightVoltageRegister, 0); + } + if (brightness_percent > 100) brightness_percent = 100; + esp_err_t error = update_register( + s_pmu, PmuLdoEnableRegister, PmuBacklightEnable, true); + if (error != ESP_OK) return error; + const uint8_t voltage = static_cast( + 20 + 8 * brightness_percent / 100); + return write_register(s_pmu, PmuBacklightVoltageRegister, voltage); +} + +static esp_err_t set_speaker_hardware(bool enabled) noexcept +{ + if (s_io_expander == nullptr) + return ESP_ERR_INVALID_STATE; + if (enabled) { + const esp_err_t error = update_register( + s_io_expander, IoExpanderPort0Register, + IoExpanderSpeakerReset, true); + if (error == ESP_OK) vTaskDelay(pdMS_TO_TICKS(2)); + return error; + } + const esp_err_t reset_error = update_register( + s_io_expander, IoExpanderPort0Register, + IoExpanderSpeakerReset, false); + return reset_error; +} /* Releases the waiting presenter when the asynchronous LCD DMA transfer ends. */ static bool transfer_complete(esp_lcd_panel_io_handle_t panel_io, @@ -55,6 +146,12 @@ esp_err_t initialize() noexcept BSP_LCD_H_RES, BSP_LCD_V_RES, Width, Height); return ESP_ERR_INVALID_SIZE; } + esp_err_t error = esp_pm_lock_create( + ESP_PM_NO_LIGHT_SLEEP, 0, "display", &s_display_sleep_lock); + if (error != ESP_OK) return error; + error = esp_pm_lock_acquire(s_display_sleep_lock); + if (error != ESP_OK) return error; + s_display_sleep_lock_held = true; s_framebuffer = static_cast(heap_caps_malloc( PixelCount * sizeof(*s_framebuffer), @@ -70,7 +167,7 @@ esp_err_t initialize() noexcept const bsp_display_config_t display_config = { .max_transfer_sz = PixelCount * sizeof(*s_framebuffer), }; - esp_err_t error = bsp_display_new( + error = bsp_display_new( &display_config, &s_panel, &s_panel_io); if (error != ESP_OK) return error; const esp_lcd_panel_io_callbacks_t callbacks = { @@ -86,7 +183,13 @@ esp_err_t initialize() noexcept esp_err_to_name(error)); return error; } - ESP_LOGI(TAG, "CoreS3 LCD and touch initialized"); + if ((error = add_power_devices()) != ESP_OK || + (error = set_backlight(NormalBrightness)) != ESP_OK) { + ESP_LOGE(TAG, "CoreS3 power initialization failed: %s", + esp_err_to_name(error)); + return error; + } + ESP_LOGI(TAG, "CoreS3 LCD, touch, and backlight power initialized"); return ESP_OK; } @@ -127,14 +230,23 @@ esp_err_t setDisplayPower(DisplayPower power) noexcept if (s_panel == nullptr) return ESP_ERR_INVALID_STATE; esp_err_t error; if (power != DisplayPower::Off) { + if (!s_display_sleep_lock_held) { + error = esp_pm_lock_acquire(s_display_sleep_lock); + if (error != ESP_OK) return error; + s_display_sleep_lock_held = true; + } error = esp_lcd_panel_disp_on_off(s_panel, true); if (error == ESP_OK) - error = bsp_display_brightness_set( + error = set_backlight( power == DisplayPower::Normal ? NormalBrightness : DimmedBrightness); } else { - error = bsp_display_brightness_set(0); + error = set_backlight(0); if (error == ESP_OK) error = esp_lcd_panel_disp_on_off(s_panel, false); + if (error == ESP_OK && s_display_sleep_lock_held) { + error = esp_pm_lock_release(s_display_sleep_lock); + if (error == ESP_OK) s_display_sleep_lock_held = false; + } } return error; } @@ -177,9 +289,17 @@ std::optional battery() noexcept /* Acquires the BSP codec speaker and enables its output path. */ esp_err_t initializeSpeaker() noexcept { - if (s_speaker != nullptr) return ESP_OK; - s_speaker = bsp_audio_codec_speaker_init(); - if (s_speaker == nullptr) return ESP_FAIL; + if (s_speaker_open) return ESP_OK; + const esp_err_t reset_error = set_speaker_hardware(true); + if (reset_error != ESP_OK) return reset_error; + if (s_speaker == nullptr) { + s_speaker = bsp_audio_codec_speaker_init(); + if (s_speaker == nullptr) { + (void)set_speaker_hardware(false); + return ESP_FAIL; + } + (void)esp_codec_set_disable_when_closed(s_speaker, true); + } esp_codec_dev_sample_info_t format = { .bits_per_sample = 16, .channel = 1, @@ -188,13 +308,31 @@ esp_err_t initializeSpeaker() noexcept .mclk_multiple = 0, }; int error = esp_codec_dev_open(s_speaker, &format); - if (error != ESP_CODEC_DEV_OK) return error; + if (error != ESP_CODEC_DEV_OK) { + (void)set_speaker_hardware(false); + return error; + } error = esp_codec_dev_set_out_vol(s_speaker, 38); - if (error != ESP_CODEC_DEV_OK) return error; + if (error != ESP_CODEC_DEV_OK) { + (void)esp_codec_dev_close(s_speaker); + (void)set_speaker_hardware(false); + return error; + } + s_speaker_open = true; ESP_LOGI(TAG, "CoreS3 speaker initialized"); return ESP_OK; } +esp_err_t shutdownSpeaker() noexcept +{ + if (s_speaker == nullptr || !s_speaker_open) return ESP_OK; + const int close_error = esp_codec_dev_close(s_speaker); + s_speaker_open = false; + const esp_err_t error = set_speaker_hardware(false); + if (close_error != ESP_CODEC_DEV_OK) return close_error; + return error; +} + /* Synthesizes one square-wave tone and writes it to the speaker codec. */ static esp_err_t play_tone(unsigned frequency, unsigned duration_ms) { @@ -372,18 +510,24 @@ esp_err_t initializeImu() noexcept error = bmi270_create(&driver, &s_imu); if (error != ESP_OK) return error; const bmi270_config_t measurement = { - .acce_odr = BMI270_ACC_ODR_50_HZ, + .acce_odr = BMI270_ACC_ODR_12_5_HZ, .acce_range = BMI270_ACC_RANGE_4_G, - .gyro_odr = BMI270_GYR_ODR_50_HZ, + .gyro_odr = BMI270_GYR_ODR_25_HZ, .gyro_range = BMI270_GYR_RANGE_500_DPS, }; error = bmi270_start(s_imu, &measurement); + if (error == ESP_OK) { + /* Gesture detection only consumes acceleration; leave gyro and + temperature blocks off after the driver configures the sensor. */ + error = write_register(s_imu->i2c_handle, ImuPowerControlRegister, + ImuAccelerometerEnable); + } if (error != ESP_OK) { bmi270_delete(s_imu); s_imu = nullptr; return error; } - ESP_LOGI(TAG, "BMI270 IMU initialized at address 0x69"); + ESP_LOGI(TAG, "BMI270 accelerometer initialized at 12.5 Hz"); return ESP_OK; } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 91bcfca..ccaaf58 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -58,7 +58,7 @@ The following values are source-observed and reported reference-verified: | Appearance | Generic HID | | Advertised service | HID service created by the BLE HID implementation | | Scan response | Enabled | -| Preferred connection interval | Minimum `0x06`, maximum `0x12` | +| Preferred connection interval | Minimum `0x18`, maximum `0x28` (30-50 ms) | | Pairing | Bonding, no input/output capability (Just Works) | The reference pre-swaps the 16-bit PnP values because its Arduino-ESP32 2.x HID diff --git a/main/claude_runtime.cpp b/main/claude_runtime.cpp index 1d8f841..ff79586 100644 --- a/main/claude_runtime.cpp +++ b/main/claude_runtime.cpp @@ -199,6 +199,7 @@ bool ClaudeRuntime::processLine() noexcept playSound(platform::Sound::Error); } else if ((!promptWasActive && model_.promptActive) || (waitingBefore == 0 && model_.waitingSessions > 0)) { + displayPowerPolicy_.recordAttention(context.nowMs); wakeDisplay(context.nowMs); playSound(platform::Sound::Attention); } else if (runningBefore > 0 && model_.runningSessions == 0 && @@ -313,7 +314,10 @@ void ClaudeRuntime::run() noexcept { ESP_LOGI(Tag, "Claude Owl Buddy starting on M5Stack CoreS3"); claude::init(model_); - ESP_ERROR_CHECK(claude::storage::load(model_)); + const esp_err_t storageLoadError = claude::storage::load(model_); + if (storageLoadError != ESP_OK) + ESP_LOGW(Tag, "Claude stats could not be loaded: %s", + esp_err_to_name(storageLoadError)); const audio::Initialization audioInitialization = audio_.initialize(); if (audioInitialization.settingsError != ESP_OK) ESP_LOGW(Tag, "Mute setting could not be loaded: %s", @@ -354,7 +358,9 @@ void ClaudeRuntime::run() noexcept for (;;) { Event event; bool redraw = false; - if (queue_.receive(event, pdMS_TO_TICKS(12))) { + const TickType_t wait = displayPower_ == platform::DisplayPower::Off + ? pdMS_TO_TICKS(MotionPollMs) : pdMS_TO_TICKS(25); + if (queue_.receive(event, wait)) { switch (event.type) { case EventType::Connection: claude::setConnection( @@ -403,8 +409,10 @@ void ClaudeRuntime::run() noexcept redraw |= wasOff && desired != platform::DisplayPower::Off; } } - if (displayPower_ != platform::DisplayPower::Off && - now - lastAnimationMs_ >= AnimationPeriodMs) { + const std::uint32_t refreshPeriod = model_.page == claude::Page::Clock + ? ClockRefreshPeriodMs : PetAnimationPeriodMs; + if (displayPower_ == platform::DisplayPower::Normal && + now - lastAnimationMs_ >= refreshPeriod) { lastAnimationMs_ = now; redraw |= model_.page == claude::Page::Pet || model_.page == claude::Page::Clock; diff --git a/main/claude_runtime.hpp b/main/claude_runtime.hpp index 04d430d..1f20354 100644 --- a/main/claude_runtime.hpp +++ b/main/claude_runtime.hpp @@ -52,8 +52,9 @@ class ClaudeRuntime final : public claude::transport::Listener { static constexpr std::size_t QueueCapacity = 12; static constexpr std::uint32_t SnapshotTimeoutMs = 30'000; - static constexpr std::uint32_t MotionPollMs = 40; - static constexpr std::uint32_t AnimationPeriodMs = 120; + static constexpr std::uint32_t MotionPollMs = 80; + static constexpr std::uint32_t PetAnimationPeriodMs = 160; + static constexpr std::uint32_t ClockRefreshPeriodMs = 1000; claude::Model model_{}; claude::Decoder decoder_{}; diff --git a/main/codex_runtime.cpp b/main/codex_runtime.cpp index 3ada3dc..510277e 100644 --- a/main/codex_runtime.cpp +++ b/main/codex_runtime.cpp @@ -148,6 +148,7 @@ bool CodexRuntime::processCompleteRequest() noexcept bool changed = false; platform::Sound cue = platform::Sound::Complete; unsigned cuePriority = 0; + bool attentionRaised = false; for (std::size_t index = 0; index < result.eventCount; ++index) { const codex::Event &event = result.events[index]; const auto *status = std::get_if(&event); @@ -165,6 +166,7 @@ bool CodexRuntime::processCompleteRequest() noexcept case codex::SlotStatus::RequiresInput: priority = 3; candidate = platform::Sound::Attention; + attentionRaised = true; break; case codex::SlotStatus::Error: priority = 2; @@ -182,6 +184,11 @@ bool CodexRuntime::processCompleteRequest() noexcept cue = candidate; } } + if (attentionRaised) { + const std::uint32_t now = uptimeMs(); + displayPowerPolicy_.recordAttention(now); + wakeDisplay(now); + } if (cuePriority > 0) playSound(cue); const esp_err_t error = codex::transport::sendJson(result.responseView()); if (error != ESP_OK) @@ -259,7 +266,7 @@ bool CodexRuntime::processTouch() noexcept bool CodexRuntime::animationDue() noexcept { - if (displayPower_ == platform::DisplayPower::Off || + if (displayPower_ != platform::DisplayPower::Normal || model_.page != codex::Page::Agents || codex::overlay(model_) != codex::Overlay::None) return false; const bool breathing = std::ranges::any_of(model_.slots, [](const auto &slot) { @@ -304,7 +311,9 @@ void CodexRuntime::run() noexcept for (;;) { Event event; bool redraw = false; - if (queue_.receive(event, pdMS_TO_TICKS(8))) { + const TickType_t wait = displayPower_ == platform::DisplayPower::Off + ? pdMS_TO_TICKS(IdlePollMs) : pdMS_TO_TICKS(ActivePollMs); + if (queue_.receive(event, wait)) { if (event.type == EventType::Connection) redraw |= applyConnection(event.connected); else diff --git a/main/codex_runtime.hpp b/main/codex_runtime.hpp index 78be55a..eae689d 100644 --- a/main/codex_runtime.hpp +++ b/main/codex_runtime.hpp @@ -48,7 +48,9 @@ class CodexRuntime final : public codex::transport::Listener, [[nodiscard]] bool updateDisplayPower(std::uint32_t nowMs) noexcept; static constexpr std::size_t QueueCapacity = 12; - static constexpr std::uint32_t AnimationPeriodMs = 80; + static constexpr std::uint32_t AnimationPeriodMs = 120; + static constexpr std::uint32_t IdlePollMs = 250; + static constexpr std::uint32_t ActivePollMs = 25; codex::Model model_{}; codex::Decoder decoder_{}; diff --git a/main/display_power_policy.hpp b/main/display_power_policy.hpp index b9e781e..42992d6 100644 --- a/main/display_power_policy.hpp +++ b/main/display_power_policy.hpp @@ -10,16 +10,26 @@ class DisplayPowerPolicy { public: static constexpr std::uint32_t DimTimeoutMs = 15'000; static constexpr std::uint32_t OffTimeoutMs = 60'000; + static constexpr std::uint32_t AttentionHoldMs = 60'000; void recordInteraction(std::uint32_t nowMs) noexcept { lastInteractionMs_ = nowMs; } + void recordAttention(std::uint32_t nowMs) noexcept + { + lastInteractionMs_ = nowMs; + lastAttentionMs_ = nowMs; + attentionRecorded_ = true; + } + [[nodiscard]] DisplayPowerLevel desired( std::uint32_t nowMs, bool attentionRequired) const noexcept { - if (attentionRequired) return DisplayPowerLevel::Normal; + if (attentionRequired && attentionRecorded_ && + nowMs - lastAttentionMs_ < AttentionHoldMs) + return DisplayPowerLevel::Normal; const std::uint32_t idleMs = nowMs - lastInteractionMs_; if (idleMs >= OffTimeoutMs) return DisplayPowerLevel::Off; if (idleMs >= DimTimeoutMs) return DisplayPowerLevel::Dimmed; @@ -28,6 +38,8 @@ class DisplayPowerPolicy { private: std::uint32_t lastInteractionMs_{}; + std::uint32_t lastAttentionMs_{}; + bool attentionRecorded_{}; }; } // namespace buddy::runtime diff --git a/main/main.cpp b/main/main.cpp index 07fa0b7..b6a5a6d 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -1,6 +1,7 @@ #include "buddy_ui.hpp" #include "claude_runtime.hpp" #include "codex_runtime.hpp" +#include "display_power_policy.hpp" #include "esp_err.h" #include "esp_log.h" #include "esp_pm.h" @@ -8,6 +9,7 @@ #include "freertos/task.h" #include "nvs_flash.h" #include "platform_core_s3.hpp" +#include "runtime_clock.hpp" namespace { @@ -30,7 +32,7 @@ esp_err_t initializePowerManagement() noexcept const esp_pm_config_t config = { .max_freq_mhz = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ, .min_freq_mhz = 40, - .light_sleep_enable = false, + .light_sleep_enable = true, }; return esp_pm_configure(&config); } @@ -39,14 +41,44 @@ buddy::display::Mode chooseMode() noexcept { buddy::display::renderSelector(buddy::platform::framebuffer()); ESP_ERROR_CHECK(buddy::platform::present()); + buddy::runtime::DisplayPowerPolicy powerPolicy; + auto displayPower = buddy::runtime::DisplayPowerLevel::Normal; + powerPolicy.recordInteraction(buddy::runtime::uptimeMs()); for (;;) { buddy::platform::TouchEvent touch; - ESP_ERROR_CHECK(buddy::platform::pollTouch(touch)); + const esp_err_t touchError = buddy::platform::pollTouch(touch); + if (touchError != ESP_OK) { + ESP_LOGW(Tag, "Mode selector touch read failed: %s", + esp_err_to_name(touchError)); + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } if (touch.type == buddy::platform::TouchType::Pressed) { + const std::uint32_t now = buddy::runtime::uptimeMs(); + powerPolicy.recordInteraction(now); + if (displayPower != buddy::runtime::DisplayPowerLevel::Normal) { + ESP_ERROR_CHECK(buddy::platform::setDisplayPower( + buddy::platform::DisplayPower::Normal)); + displayPower = buddy::runtime::DisplayPowerLevel::Normal; + vTaskDelay(pdMS_TO_TICKS(40)); + continue; + } const auto mode = buddy::display::selectorHit(touch.x, touch.y); if (mode != buddy::display::Mode::None) return mode; } - vTaskDelay(pdMS_TO_TICKS(8)); + const auto desired = powerPolicy.desired( + buddy::runtime::uptimeMs(), false); + if (desired != displayPower) { + const auto platformPower = desired == buddy::runtime::DisplayPowerLevel::Normal + ? buddy::platform::DisplayPower::Normal + : desired == buddy::runtime::DisplayPowerLevel::Dimmed + ? buddy::platform::DisplayPower::Dimmed + : buddy::platform::DisplayPower::Off; + ESP_ERROR_CHECK(buddy::platform::setDisplayPower(platformPower)); + displayPower = desired; + } + vTaskDelay(pdMS_TO_TICKS( + displayPower == buddy::runtime::DisplayPowerLevel::Off ? 250 : 40)); } } diff --git a/sdkconfig.defaults b/sdkconfig.defaults index e335716..9cbda97 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -8,6 +8,9 @@ CONFIG_BT_CTRL_MODEM_SLEEP=y CONFIG_BT_CTRL_MODEM_SLEEP_MODE_1=y CONFIG_BT_CTRL_LPCLK_SEL_MAIN_XTAL=y CONFIG_PM_ENABLE=y +CONFIG_FREERTOS_USE_TICKLESS_IDLE=y +CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP=3 +CONFIG_BT_CTRL_MAIN_XTAL_PU_DURING_LIGHT_SLEEP=y CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_QUAD=y CONFIG_SPIRAM_SPEED_80M=y diff --git a/test/host/test_display_power_policy.cpp b/test/host/test_display_power_policy.cpp index 743dcd7..5845a39 100644 --- a/test/host/test_display_power_policy.cpp +++ b/test/host/test_display_power_policy.cpp @@ -13,9 +13,13 @@ int main() assert(policy.desired(15'999, false) == DisplayPowerLevel::Normal); assert(policy.desired(16'000, false) == DisplayPowerLevel::Dimmed); assert(policy.desired(61'000, false) == DisplayPowerLevel::Off); - assert(policy.desired(500'000, true) == DisplayPowerLevel::Normal); + assert(policy.desired(500'000, true) == DisplayPowerLevel::Off); assert(policy.desired(500'000, false) == DisplayPowerLevel::Off); + policy.recordAttention(600'000); + assert(policy.desired(659'999, true) == DisplayPowerLevel::Normal); + assert(policy.desired(660'000, true) == DisplayPowerLevel::Off); + policy.recordInteraction(UINT32_MAX - 9'999U); assert(policy.desired(5'000, false) == DisplayPowerLevel::Dimmed); return 0;