Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions components/buddy_audio/buddy_audio.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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
55 changes: 55 additions & 0 deletions components/buddy_layout/include/buddy_glyph_font.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#pragma once

#include <cctype>
#include <cstdint>

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<char>(toupper(static_cast<unsigned char>(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
28 changes: 28 additions & 0 deletions components/buddy_layout/include/buddy_layout.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint16_t>(OriginX + column * ColumnStride),
static_cast<std::uint16_t>(OriginY + row * RowStride), CellWidth,
CellHeight};
}
} // namespace codex_grid

} // namespace buddy::layout
65 changes: 12 additions & 53 deletions components/buddy_ui/buddy_ui.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#include "buddy_ui.hpp"

#include <cctype>
#include <cstddef>
#include <cstdio>
#include <cstring>
Expand All @@ -9,6 +8,8 @@

#ifdef BUDDY_USE_LVGL_FONT
#include "ui_font.hpp"
#else
#include "buddy_glyph_font.hpp"
#endif

namespace buddy::display {
Expand Down Expand Up @@ -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<char>(
toupper(static_cast<unsigned char>(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)
{
Expand All @@ -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)
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions components/claude_ble_transport/claude_ble_transport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
92 changes: 10 additions & 82 deletions components/claude_protocol/claude_protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
#include <algorithm>
#include <array>
#include <cstdio>
#include <cstdlib>
#include <cstring>

#include "fixed_json.hpp"
Expand All @@ -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<std::size_t>(token.end - token.start);
if (length == 0 || length >= 32 || json[static_cast<std::size_t>(token.start)] == '-')
return false;
std::array<char, 32> buffer{};
json.copy(buffer.data(), length, static_cast<std::size_t>(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<std::uint64_t>(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<std::size_t>(token.end - token.start);
if (length == 0 || length >= 32) return false;
std::array<char, 32> buffer{};
json.copy(buffer.data(), length, static_cast<std::size_t>(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<std::int64_t>(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
{
Expand All @@ -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<char> 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<char> 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<std::size_t>(position)];
if (byte != '\\') {
destination[written++] = byte;
continue;
}
if (++position >= token.end) break;
switch (json[static_cast<std::size_t>(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
Expand Down Expand Up @@ -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<std::size_t>(message)], model.message);
tokenStringCopy(json, parser.tokens[static_cast<std::size_t>(message)], model.message);

model.entries.fill({});
const int entries = objectValue(json, parser, 0, "entries");
Expand All @@ -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;
}
}
Expand All @@ -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<std::size_t>(id)].type == TokenType::String) {
copyString(json, parser.tokens[static_cast<std::size_t>(id)], model.promptId);
tokenStringCopy(json, parser.tokens[static_cast<std::size_t>(id)], model.promptId);
model.promptActive = model.promptId[0] != '\0';
}
if (tool >= 0)
copyString(json, parser.tokens[static_cast<std::size_t>(tool)], model.promptTool);
tokenStringCopy(json, parser.tokens[static_cast<std::size_t>(tool)], model.promptTool);
if (hint >= 0)
copyString(json, parser.tokens[static_cast<std::size_t>(hint)], model.promptHint);
tokenStringCopy(json, parser.tokens[static_cast<std::size_t>(hint)], model.promptHint);
}
if (previousRunning > 0 && running == 0 && waiting == 0 && !model.promptActive) {
model.transientState = PetState::Celebrate;
Expand Down Expand Up @@ -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<std::size_t>(value)],
tokenStringCopy(json, parser.tokens[static_cast<std::size_t>(value)],
owner ? std::span<char>{model.owner} : std::span<char>{model.deviceName});
action.modelChanged = true;
action.persistModel = true;
Expand All @@ -301,7 +229,7 @@ bool commandResponse(std::string_view json, const Parser &parser, int command,
}

std::array<char, 32> 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()));
}
Expand Down
Loading