From f83fb1c200b0d9ba3a48e1a8479f0fbf51f7b816 Mon Sep 17 00:00:00 2001 From: lakphy Date: Sun, 26 Apr 2026 00:59:51 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Buddy=20?= =?UTF-8?q?=E7=A1=AC=E4=BB=B6=E6=A1=8C=E5=AE=A0=E7=9A=84=20README=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- hardware/README.md | 172 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 hardware/README.md diff --git a/hardware/README.md b/hardware/README.md new file mode 100644 index 00000000..f949da79 --- /dev/null +++ b/hardware/README.md @@ -0,0 +1,172 @@ +# Buddy — CodeIsland 的硬件桌宠 + +> 把 macOS 灵动岛上的 AI Coding Agent 状态动画,搬到一颗放在桌上的 ESP32 小屏幕上。 + +Buddy 是 [CodeIsland](https://github.com/wxtsky/CodeIsland) 的硬件外设功能。Mac 上运行的 CodeIsland 通过蓝牙低功耗(BLE)把当前 AI Coding Agent(Claude / Codex / Gemini / Cursor / Copilot / Trae / Qoder / Factory / CodeBuddy / OpenCode / Kimi / …)的工作状态实时推送给 ESP32,ESP32 在 1.47 寸彩屏上播放对应的像素吉祥物动画: + +- **空闲(idle)→ Sleep 场景**:吉祥物闭眼休眠 +- **处理中(processing / running)→ Work 场景**:吉祥物在敲代码 +- **等待批准 / 等待回答(waitApproval / waitQuestion)→ Alert 场景**:吉祥物呼叫你 + +未连接 BLE 时,Buddy 会显示引导页(含项目 GitHub 二维码与设备名);长按按键即可切到 Demo 模式,自动轮播全部 16 只吉祥物。 + +--- + +## 1. 准备硬件 + +Buddy 目前只适配下面这一款开发板,零售价约 60 CNY,淘宝上的第三方仿品同样可用。 + +| 项目 | 型号 / 参数 | +| --- | --- | +| 开发板 | **Waveshare ESP32-C6-LCD-1.47**(微雪电子) | +| 主控 | ESP32-C6FH4(RISC-V 单核,160 MHz,4 MB Flash) | +| 屏幕 | 1.47 寸 IPS,172×320,ST7789 驱动 | +| 无线 | Wi-Fi 6 + BLE 5(本项目使用 BLE) | + +**购买参考(非赞助):** + +- 微雪官方:搜索关键字 `ESP32-C6-LCD-1.47` + - 产品页: + - Wiki: + +> **关于按钮**:板上的 `BOOT` 键已被复用为业务按键(GPIO9),无需外接按钮——短按切换吉祥物、长按切换 Demo 模式。 + +更多板子细节见 [HARDWARE_NOTES.md](HARDWARE_NOTES.md)。 + +--- + +## 2. 拉取代码 + +```bash +git clone https://github.com/wxtsky/CodeIsland.git +cd CodeIsland/hardware +``` + +`hardware/` 目录关键文件: + +- [hardware.ino](hardware.ino) — 主程序入口(Arduino sketch) +- `mascot_*.h` — 16 只吉祥物的像素动画绘制函数 +- [HARDWARE_NOTES.md](HARDWARE_NOTES.md) — 板子引脚 / 上传踩坑笔记 +- [RENDER_OPTIMIZATION.md](RENDER_OPTIMIZATION.md) — 双缓冲渲染优化笔记 + +--- + +## 3. 安装 Arduino IDE + +到 下载 **Arduino IDE 2.x**(macOS Universal 安装包),双击安装即可。 + +> 偏好 PlatformIO / arduino-cli 的同学可以照搬下方依赖清单,本指南以官方 IDE 2.x 为准。 + +--- + +## 4. 添加 ESP32 开发板支持 + +ESP32-C6 需要 **Arduino-ESP32 v3.0 或更高**。 + +1. 打开 **Arduino IDE → Settings…**(快捷键 `⌘,`)。 +2. 在 **Additional boards manager URLs** 里添加(已有其它链接时用逗号分隔): + + ``` + https://espressif.github.io/arduino-esp32/package_esp32_index.json + ``` + +3. 打开 **Tools → Board → Boards Manager…**,搜索 `esp32`,安装 **"esp32 by Espressif Systems"**(≥ 3.0.0)。 +4. 安装完成后,**Tools → Board → esp32** 列表里应能看到 `ESP32C6 Dev Module`。 + +--- + +## 5. 安装依赖库 + +打开 **Sketch → Include Library → Manage Libraries…**,分别搜索并安装: + +| 库名 | 作者 | 用途 | +| --- | --- | --- | +| `Adafruit GFX Library` | Adafruit | 2D 图形基础库 | +| `Adafruit ST7735 and ST7789 Library` | Adafruit | ST7789 屏幕驱动 | +| `Adafruit BusIO` | Adafruit | 前两者的依赖(IDE 通常会自动提示一起装) | + +> **BLE 库无需单独安装**:`BLEDevice` / `BLEServer` / `BLE2902` 已由 Arduino-ESP32 开发板包内置提供。 + +安装完毕后建议重启一次 IDE。 + +--- + +## 6. 打开工程 + +1. 在 Arduino IDE 中点击 **File → Open…**,选择 `CodeIsland/hardware/hardware.ino`。 +2. 若 IDE 弹出 "this sketch needs to be inside a folder named hardware" 的提示,直接确认/忽略即可——文件本身就在 `hardware/` 目录里。 +3. 打开后侧栏应能看到 `hardware.ino` 以及全部 `mascot_*.h` 头文件。 + +--- + +## 7. 配置开发板参数 + +用 USB-C 数据线把 ESP32-C6 接到 Mac,然后在 **Tools** 菜单里设置: + +| 选项 | 推荐值 | +| --- | --- | +| Board | **ESP32C6 Dev Module** | +| USB CDC On Boot | **Enabled**(必须,否则串口不工作) | +| CPU Frequency | 160 MHz | +| Flash Size | 4 MB (32 Mb) | +| Partition Scheme | Default 4MB with spiffs(保持默认) | +| Upload Speed | 921600(失败时降到 460800) | +| Port | `/dev/cu.usbmodem*` | + +> 在 Port 列表里看不到 `/dev/cu.usbmodem*`?大概率是数据线只能充电。ESP32-C6 走原生 USB,macOS 免驱,换一根能传数据的线即可。 + +--- + +## 8. 烧录固件 + +1. 点击工具栏 **✓ Verify** 编译,首次编译耗时较长,请耐心等待。 +2. 编译通过后点击 **→ Upload**。 +3. 若上传卡在 `Connecting...`,按以下顺序手动进入下载模式: + 1. 按住板上的 **BOOT** 键 + 2. 点按一下 **RESET** 键 + 3. 松开 **BOOT** 键 + 4. 立即重新点 **Upload** +4. 上传完成后开发板会自动复位,显示引导页(含 GitHub 二维码与设备名 `Buddy-XXXXXX`)。 + +--- + +## 9. 与 macOS 端 CodeIsland 配对 + +1. 启动 [CodeIsland](https://github.com/wxtsky/CodeIsland) 主程序(需为支持 ESP32 桥接的版本)。 +2. 打开 **Preferences → ESP32 / Buddy** 面板,启用桥接开关。 +3. 首次连接时 macOS 会请求蓝牙权限,授权后等待扫描到 `Buddy-XXXXXX`,点击连接。 +4. 触发任意 AI Coding Agent(例如让 Claude Code 跑一条命令),Buddy 屏幕会立即切到对应吉祥物的 **Work** 场景。 +5. 在 macOS 端可远程调节屏幕亮度(10%–100%)与翻转方向(朝上 / 朝下),无需重新烧录。 + +> 一直扫不到设备?请到 **系统设置 → 隐私与安全性 → 蓝牙** 确认 CodeIsland 已被授权,然后关掉再打开 Buddy 桥接开关重新触发扫描。 + +--- + +## 10. 按键说明 + +| 操作 | 行为 | +| --- | --- | +| 短按 BOOT | 切换到下一只吉祥物(Onboard / Demo 模式生效;BLE 已连上时由 Mac 决定显示哪只) | +| 长按 ≥ 0.6 秒 | 切换 Demo 模式(自动轮播全部吉祥物) | + +--- + +## 11. 串口调试 + +- 波特率:`115200` +- 启动时打印:开发板信息、BLE 设备名(`Buddy-XXXXXX`) +- 此后每隔 ~2 秒打印一次:当前吉祥物 / 场景 / FPS / BLE 状态,便于排查问题 + +--- + +## 12. FAQ + +Buddy 基于 Arduino 平台开发,上手门槛非常低;遇到问题时建议直接交给 AI 辅助排查。 + +--- + +## 许可证 + +与主仓库一致,见根目录 [LICENSE](../LICENSE)。 + +Have fun & happy hacking — 让你的桌面也拥有一只 Buddy 🐾 From 5205a8ad11c2003bcf5f94f1486ee8bb78d57299 Mon Sep 17 00:00:00 2001 From: lakphy Date: Sun, 26 Apr 2026 01:04:40 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Buddy=20?= =?UTF-8?q?=E6=A1=8C=E5=AE=A0=E4=BD=BF=E7=94=A8=E5=89=8D=E6=8F=90=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hardware/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hardware/README.md b/hardware/README.md index f949da79..fe46214a 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -12,6 +12,10 @@ Buddy 是 [CodeIsland](https://github.com/wxtsky/CodeIsland) 的硬件外设功 --- +## 0. 前提 + +在上手 Buddy 桌宠之前,你需要有充足的动手能力(上手过程会比较坎坷)和基本的 AI 使用能力(便于辅助排查问题)。 + ## 1. 准备硬件 Buddy 目前只适配下面这一款开发板,零售价约 60 CNY,淘宝上的第三方仿品同样可用。 From 50157405a7299690d8d73dd76f0bc471c3c94b11 Mon Sep 17 00:00:00 2001 From: lakphy Date: Mon, 11 May 2026 23:22:49 +0800 Subject: [PATCH 3/7] Add question alert functionality to various mascot modules - Introduced `*_Question` functions in multiple mascot header files to enable a question alert mode. - Updated `drawBang` function to render a question mark when `_questionMode` is active. - Modified `clawdSleep` to include a bored yawn animation based on global bored state. - Added global variables for bored state and eye offset to enhance mascot behavior. - Implemented tool icon classification and drawing functions for better visual representation of tools. --- Sources/CodeIsland/ESP32BridgeManager.swift | 189 +++- Sources/CodeIsland/ESP32StatePublisher.swift | 71 +- Sources/CodeIsland/L10n.swift | 25 + Sources/CodeIsland/SettingsView.swift | 31 +- Sources/CodeIslandCore/ESP32Protocol.swift | 211 +++- Sources/CodeIslandCore/SessionSnapshot.swift | 2 + .../ESP32ProtocolTests.swift | 162 ++++ .../AppStatePrimarySourceTests.swift | 53 ++ hardware/README.md | 4 +- hardware/hardware.ino | 898 ++++++++++++++++-- hardware/mascot_antigrav.h | 6 + hardware/mascot_buddy.h | 6 + hardware/mascot_clawd.h | 12 +- hardware/mascot_common.h | 97 ++ hardware/mascot_copilot.h | 6 + hardware/mascot_cursor.h | 6 + hardware/mascot_dex.h | 6 + hardware/mascot_droid.h | 6 + hardware/mascot_gemini.h | 6 + hardware/mascot_hermes.h | 6 + hardware/mascot_kimi.h | 6 + hardware/mascot_opencode.h | 6 + hardware/mascot_qoder.h | 6 + hardware/mascot_qwen.h | 6 + hardware/mascot_stepfun.h | 6 + hardware/mascot_trae.h | 6 + hardware/mascot_workbuddy.h | 6 + 27 files changed, 1768 insertions(+), 77 deletions(-) diff --git a/Sources/CodeIsland/ESP32BridgeManager.swift b/Sources/CodeIsland/ESP32BridgeManager.swift index 4b85e994..5c7c68fe 100644 --- a/Sources/CodeIsland/ESP32BridgeManager.swift +++ b/Sources/CodeIsland/ESP32BridgeManager.swift @@ -12,6 +12,9 @@ enum ESP32BridgeStatus: Equatable { case scanning // discovery mode: enumerating nearby Buddies for the user case searchingSelected // looking for the previously-selected Buddy case connecting // found the selected one, connecting / discovering characteristics + case pairing // BLE connected, pair request sent, waiting for Buddy response + case pairWaitingConfirm // Buddy shows confirmation screen, waiting for user button press + case pairRejected // Buddy is paired with another host case connected // ready to write + receiving notifications case reconnecting(Int) // seconds until next attempt to find the selected Buddy @@ -23,6 +26,9 @@ enum ESP32BridgeStatus: Equatable { case .scanning: return "scanning" case .searchingSelected: return "searching selected" case .connecting: return "connecting" + case .pairing: return "pairing" + case .pairWaitingConfirm: return "confirm on Buddy" + case .pairRejected: return "pair rejected" case .connected: return "connected" case .reconnecting(let s): return "reconnecting in \(s)s" } @@ -79,6 +85,15 @@ final class ESP32BridgeManager: NSObject { private var reconnectTimer: Timer? private var discoveryActive = false private var discoveryPruneTimer: Timer? + /// Stable 6-byte identifier for this Mac, used in the application-layer + /// pairing handshake so Buddy can distinguish paired hosts. + @ObservationIgnored + private var hostIdentifier: Data = loadOrCreateHostId() + /// Set to `true` inside `forgetSelection()` so the disconnect callback + /// knows not to schedule a reconnect. + private var forgetting = false + /// Fires after `pairConfirmTimeoutSeconds` if no pair response arrives. + private var pairTimeoutTimer: Timer? /// Callback fired when Buddy notifies a button press with a /// mascot `sourceId` byte. Nonisolated to allow CoreBluetooth delegate @@ -117,6 +132,7 @@ final class ESP32BridgeManager: NSObject { /// Disable the bridge, tear down peripheral + scan + discovery. func stop() { cancelReconnectTimer() + cancelPairTimeout() stopDiscoveryInternal(updateStatus: false) if let central, central.isScanning { central.stopScan() } if let peripheral, let central { @@ -181,16 +197,33 @@ final class ESP32BridgeManager: NSObject { attemptReconnectToSelected() } - /// Forget the selected Buddy: disconnect, clear persisted identifier, - /// and stop reconnecting. + /// Forget the selected Buddy: send an unpair command so the Buddy clears + /// its NVS, then disconnect and clear all persisted state. func forgetSelection() { cancelReconnectTimer() + cancelPairTimeout() + forgetting = true + + // Tell Buddy to clear its paired-host record before we drop the link. + // Use .withResponse so we wait for the write ACK before disconnecting. + if let peripheral, let writeChar, + status == .connected || status == .pairing || status == .pairWaitingConfirm { + let unpair = BuddyUnpairPayload(hostId: hostIdentifier) + peripheral.writeValue(unpair.encode(), for: writeChar, type: .withResponse) + Self.log.info("Sent unpair frame (withResponse), will disconnect on ACK") + return + } + completeForget() + } + + private func completeForget() { if let peripheral, let central { central.cancelPeripheralConnection(peripheral) } peripheral = nil writeChar = nil notifyChar = nil + notifySubscriptionReady = false connectedPeripheralName = nil selectedBuddyIdentifier = nil selectedBuddyName = nil @@ -199,6 +232,7 @@ final class ESP32BridgeManager: NSObject { if status != .off { status = .noSelection } + forgetting = false } // MARK: - Public writes @@ -218,6 +252,36 @@ final class ESP32BridgeManager: NSObject { send(preview.encode()) } + /// Write model info frame to Buddy. No-op when not connected. + func sendModel(_ model: BuddyModelPayload) { + send(model.encode()) + } + + /// Write session stats frame to Buddy. No-op when not connected. + func sendStats(_ stats: BuddyStatsPayload) { + send(stats.encode()) + } + + /// Write subagent count frame to Buddy. No-op when not connected. + func sendSubagent(_ subagent: BuddySubagentPayload) { + send(subagent.encode()) + } + + /// Write event frame to Buddy. No-op when not connected. + func sendEvent(_ event: BuddyEventPayload) { + send(event.encode()) + } + + /// Write time hint frame to Buddy. No-op when not connected. + func sendTimeHint(_ timeHint: BuddyTimeHintPayload) { + send(timeHint.encode()) + } + + /// Write tool history entry frame to Buddy. No-op when not connected. + func sendToolHistory(_ entry: BuddyToolHistoryPayload) { + send(entry.encode()) + } + private func send(_ data: Data) { guard let peripheral, let writeChar, status == .connected else { return } peripheral.writeValue(data, for: writeChar, type: .withoutResponse) @@ -241,12 +305,69 @@ final class ESP32BridgeManager: NSObject { private func ensureCentral() { if central == nil { - // `queue: nil` = main queue, so delegate callbacks land on main. central = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: true]) } } + private static let hostIdDefaultsKey = "buddyHostIdentifier" + + /// Load or generate a stable 6-byte host identifier persisted in UserDefaults. + private static func loadOrCreateHostId() -> Data { + let defaults = UserDefaults.standard + if let existing = defaults.data(forKey: hostIdDefaultsKey), + existing.count == ESP32Protocol.hostIdLength { + return existing + } + var bytes = [UInt8](repeating: 0, count: ESP32Protocol.hostIdLength) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + if status != errSecSuccess { + let uuid = UUID() + let uuidBytes = withUnsafeBytes(of: uuid.uuid) { Array($0) } + bytes = Array(uuidBytes.prefix(ESP32Protocol.hostIdLength)) + } + let data = Data(bytes) + defaults.set(data, forKey: hostIdDefaultsKey) + return data + } + + /// Send a pair request frame using the raw write characteristic. + /// Called before `.connected` is reached, so we bypass the `send()` guard. + private func sendPairRequest() { + guard let peripheral, let writeChar else { return } + let payload = BuddyPairRequestPayload(hostId: hostIdentifier) + peripheral.writeValue(payload.encode(), for: writeChar, type: .withoutResponse) + Self.log.info("Pair request sent") + schedulePairTimeout() + } + + private func schedulePairTimeout() { + pairTimeoutTimer?.invalidate() + pairTimeoutTimer = Timer.scheduledTimer( + withTimeInterval: TimeInterval(ESP32Protocol.pairConfirmTimeoutSeconds), + repeats: false + ) { [weak self] _ in + Task { @MainActor in + self?.handlePairTimeout() + } + } + } + + private func cancelPairTimeout() { + pairTimeoutTimer?.invalidate() + pairTimeoutTimer = nil + } + + private func handlePairTimeout() { + guard status == .pairing || status == .pairWaitingConfirm else { return } + Self.log.error("Pair handshake timed out after \(ESP32Protocol.pairConfirmTimeoutSeconds)s") + lastError = "Pairing timed out. Buddy did not respond." + status = .pairRejected + if let peripheral, let central { + central.cancelPeripheralConnection(peripheral) + } + } + private func loadSelectionFromDefaults() { if let raw = defaults.string(forKey: SettingsKey.selectedBuddyIdentifier), !raw.isEmpty, @@ -343,8 +464,9 @@ final class ESP32BridgeManager: NSObject { } if updateStatus { // After leaving discovery, return to the appropriate state. - if peripheral != nil, status == .connected { - // already connected — keep status + if peripheral != nil, + status == .connected || status == .pairing || status == .pairWaitingConfirm || status == .connecting { + // actively connected or mid-handshake — keep status } else if selectedBuddyIdentifier != nil { attemptReconnectToSelected() } else if status != .off, status != .poweredOff { @@ -490,7 +612,18 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { self.notifyChar = nil self.notifySubscriptionReady = false self.connectedPeripheralName = nil - if self.status != .off { + if self.forgetting { + // Link dropped during forget flow (write ACK may never arrive). + // completeForget() is idempotent — safe even though peripheral + // is already nil; it clears persisted selection + resets forgetting. + self.completeForget() + } else if self.selectedBuddyIdentifier == nil { + if self.status != .off { + self.status = .noSelection + } + } else if self.status == .pairRejected { + // Don't auto-reconnect after rejection; user must act. + } else if self.status != .off { self.scheduleReconnect() } } @@ -577,17 +710,30 @@ extension ESP32BridgeManager: CBPeripheralDelegate { } guard !self.notifySubscriptionReady else { return } - Self.log.info("Buddy uplink subscription enabled") + Self.log.info("Buddy uplink subscription enabled — initiating pair handshake") self.notifySubscriptionReady = true self.reconnectAttempt = 0 self.lastError = nil - self.status = .connected if let live = peripheral.name, !live.isEmpty { self.connectedPeripheralName = live self.selectedBuddyName = live self.defaults.set(live, forKey: SettingsKey.selectedBuddyName) } - self.onConnected?() + self.status = .pairing + self.sendPairRequest() + } + } + + nonisolated func peripheral(_ peripheral: CBPeripheral, + didWriteValueFor characteristic: CBCharacteristic, + error: Error?) { + Task { @MainActor in + if self.forgetting { + if let error { + Self.log.error("Unpair write ACK error (proceeding anyway): \(error.localizedDescription)") + } + self.completeForget() + } } } @@ -605,6 +751,8 @@ extension ESP32BridgeManager: CBPeripheralDelegate { return } switch event { + case .pairResponse(let response): + self.handlePairResponse(response) case .focus(let mascot): Self.log.info("Button event: mascot=\(mascot.sourceName)") self.onFocusRequest?(mascot) @@ -614,4 +762,27 @@ extension ESP32BridgeManager: CBPeripheralDelegate { } } } + + @MainActor + private func handlePairResponse(_ response: BuddyPairResponse) { + cancelPairTimeout() + switch response { + case .accepted: + Self.log.info("Pair accepted by Buddy") + lastError = nil + status = .connected + onConnected?() + case .rejected: + Self.log.error("Pair rejected — Buddy is paired with another host") + lastError = "Buddy is paired with another device. Hold BOOT for 3s on Buddy to reset pairing." + status = .pairRejected + if let peripheral, let central { + central.cancelPeripheralConnection(peripheral) + } + case .pending: + Self.log.info("Pair pending — waiting for user confirmation on Buddy") + lastError = nil + status = .pairWaitingConfirm + } + } } diff --git a/Sources/CodeIsland/ESP32StatePublisher.swift b/Sources/CodeIsland/ESP32StatePublisher.swift index 6f3763f2..fa29b51a 100644 --- a/Sources/CodeIsland/ESP32StatePublisher.swift +++ b/Sources/CodeIsland/ESP32StatePublisher.swift @@ -24,6 +24,7 @@ final class ESP32StatePublisher { private var screenOrientation: BuddyScreenOrientation = .up private var keepAliveActivity: NSObjectProtocol? private var interactiveRetryTask: Task? + private var lastSentStatus: MascotStatusCode? private init() { self.bridge = ESP32BridgeManager.shared @@ -66,6 +67,7 @@ final class ESP32StatePublisher { } } else { endKeepAliveActivity() + lastSentStatus = nil bridge.stop() } } @@ -79,11 +81,35 @@ final class ESP32StatePublisher { private func flush(reason: String) { guard let appState else { return } guard bridge.status == .connected else { return } + guard bridge.selectedBuddyIdentifier != nil else { return } let session = appState.esp32DisplaySession() let frame = appState.esp32DisplayFrame(session: session) bridge.send(frame) bridge.sendWorkspace(appState.esp32WorkspacePayload(session: session)) appState.esp32MessagePreviewPayloads(session: session).forEach { bridge.sendMessagePreview($0) } + bridge.sendModel(appState.esp32ModelPayload(session: session)) + bridge.sendStats(appState.esp32StatsPayload(session: session)) + bridge.sendSubagent(appState.esp32SubagentPayload(session: session)) + bridge.sendTimeHint(BuddyTimeHintPayload(hour: Calendar.current.component(.hour, from: Date()))) + appState.esp32ToolHistoryPayloads(session: session).forEach { bridge.sendToolHistory($0) } + + // Detect status transitions for event animations + let currentStatus = frame.status + if let prev = lastSentStatus, prev != currentStatus { + if (prev == .processing || prev == .running) && currentStatus == .idle { + if let lastTool = session?.toolHistory.last, !lastTool.success { + bridge.sendEvent(.error) + } else { + bridge.sendEvent(.complete) + } + } + if (currentStatus == .waitingApproval || currentStatus == .waitingQuestion) + && prev != .waitingApproval && prev != .waitingQuestion { + bridge.sendEvent(.approval) + } + } + lastSentStatus = currentStatus + Self.log.debug("push(\(reason)): mascot=\(frame.mascot.sourceName) status=\(frame.status.rawValue) tool=\(frame.toolName ?? "")") } @@ -175,10 +201,18 @@ extension AppState { ) } + let sessionStatus = session?.status ?? .idle + let effectiveSource: String + if sessionStatus == .idle { + effectiveSource = SettingsManager.shared.defaultSource + } else { + effectiveSource = session?.source ?? primarySource + } + return BuddyDisplayContext( - source: session?.source ?? primarySource, - status: session?.status ?? .idle, - tool: (session?.status == .running || session?.status == .processing || session?.status == .waitingApproval || session?.status == .waitingQuestion) + source: effectiveSource, + status: sessionStatus, + tool: (sessionStatus == .running || sessionStatus == .processing || sessionStatus == .waitingApproval || sessionStatus == .waitingQuestion) ? session?.currentTool : nil, workspace: session?.projectDisplayName, @@ -227,6 +261,37 @@ extension AppState { ].joined(separator: "|") } + func esp32ModelPayload(session: SessionSnapshot? = nil) -> BuddyModelPayload { + BuddyModelPayload(modelName: session?.shortModelName) + } + + func esp32StatsPayload(session: SessionSnapshot? = nil) -> BuddyStatsPayload { + let toolCount = session?.totalToolCallCount ?? 0 + let durationMin: Int + if let start = session?.startTime { + durationMin = Int(Date().timeIntervalSince(start) / 60.0) + } else { + durationMin = 0 + } + return BuddyStatsPayload( + activeSessionCount: activeSessionCount, + totalSessionCount: totalSessionCount, + toolCallCount: toolCount, + sessionDurationMinutes: durationMin + ) + } + + func esp32SubagentPayload(session: SessionSnapshot? = nil) -> BuddySubagentPayload { + BuddySubagentPayload(count: session?.activeSubagentCount ?? 0) + } + + func esp32ToolHistoryPayloads(session: SessionSnapshot? = nil) -> [BuddyToolHistoryPayload] { + guard let history = session?.toolHistory, !history.isEmpty else { return [] } + return history.suffix(10).enumerated().map { index, entry in + BuddyToolHistoryPayload(index: index, success: entry.success, toolName: entry.tool) + } + } + func esp32MessagePreviewSegments(text: String?) -> [String] { guard let text else { return [] } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 792a498e..21b22d9d 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -252,6 +252,11 @@ final class L10n: ObservableObject { "buddy_status_scanning": "Looking for Buddy…", "buddy_status_connecting": "Connecting…", "buddy_status_connected": "Connected", + "buddy_status_pairing": "Pairing…", + "buddy_status_pair_waiting_confirm": "Press BOOT on Buddy", + "buddy_status_pair_rejected": "Pair rejected", + "buddy_pair_confirm_hint": "Press the BOOT button on Buddy to confirm pairing", + "buddy_pair_rejected_hint": "This Buddy is paired with another device. Hold BOOT for 3s on Buddy to reset.", "buddy_status_reconnecting": "Reconnecting in %d s…", "buddy_status_no_selection": "No Buddy selected", "buddy_status_searching_selected": "Waiting for selected Buddy…", @@ -556,6 +561,11 @@ final class L10n: ObservableObject { "buddy_status_scanning": "正在寻找 Buddy…", "buddy_status_connecting": "正在连接…", "buddy_status_connected": "已连接", + "buddy_status_pairing": "配对中…", + "buddy_status_pair_waiting_confirm": "请按 Buddy 上的 BOOT 按钮", + "buddy_status_pair_rejected": "配对被拒绝", + "buddy_pair_confirm_hint": "请按 Buddy 上的 BOOT 按钮确认配对", + "buddy_pair_rejected_hint": "此 Buddy 已与其他设备配对。在 Buddy 上长按 BOOT 3秒可重置配对。", "buddy_status_reconnecting": "%d 秒后重新连接…", "buddy_status_no_selection": "未选择 Buddy", "buddy_status_searching_selected": "等待已选 Buddy 出现…", @@ -860,6 +870,11 @@ final class L10n: ObservableObject { "buddy_status_scanning": "Buddy を探しています…", "buddy_status_connecting": "接続中…", "buddy_status_connected": "接続済み", + "buddy_status_pairing": "ペアリング中…", + "buddy_status_pair_waiting_confirm": "Buddy の BOOT ボタンを押してください", + "buddy_status_pair_rejected": "ペアリング拒否", + "buddy_pair_confirm_hint": "Buddy の BOOT ボタンを押してペアリングを確認してください", + "buddy_pair_rejected_hint": "この Buddy は別のデバイスとペアリング済みです。BOOT を3秒長押しでリセットできます。", "buddy_status_reconnecting": "%d 秒後に再接続…", "buddy_status_no_selection": "Buddy が未選択", "buddy_status_searching_selected": "選択した Buddy を探しています…", @@ -1164,6 +1179,11 @@ final class L10n: ObservableObject { "buddy_status_scanning": "Buddy를 찾는 중…", "buddy_status_connecting": "연결 중…", "buddy_status_connected": "연결됨", + "buddy_status_pairing": "페어링 중…", + "buddy_status_pair_waiting_confirm": "Buddy의 BOOT 버튼을 누르세요", + "buddy_status_pair_rejected": "페어링 거부됨", + "buddy_pair_confirm_hint": "Buddy의 BOOT 버튼을 눌러 페어링을 확인하세요", + "buddy_pair_rejected_hint": "이 Buddy는 다른 기기와 페어링되어 있습니다. BOOT를 3초간 길게 누르면 초기화됩니다.", "buddy_status_reconnecting": "%d초 후 다시 연결…", "buddy_status_no_selection": "선택된 Buddy 없음", "buddy_status_searching_selected": "선택한 Buddy를 기다리는 중…", @@ -1468,6 +1488,11 @@ final class L10n: ObservableObject { "buddy_status_scanning": "Buddy aranıyor…", "buddy_status_connecting": "Bağlanıyor…", "buddy_status_connected": "Bağlı", + "buddy_status_pairing": "Eşleştiriliyor…", + "buddy_status_pair_waiting_confirm": "Buddy'de BOOT'a basın", + "buddy_status_pair_rejected": "Eşleştirme reddedildi", + "buddy_pair_confirm_hint": "Eşleştirmeyi onaylamak için Buddy'deki BOOT düğmesine basın", + "buddy_pair_rejected_hint": "Bu Buddy başka bir cihazla eşleştirilmiş. Sıfırlamak için BOOT'u 3sn basılı tutun.", "buddy_status_reconnecting": "%d sn sonra yeniden bağlanıyor…", "buddy_status_no_selection": "Buddy seçilmedi", "buddy_status_searching_selected": "Seçilen Buddy bekleniyor…", diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index f349a6d2..d2461078 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -1013,6 +1013,9 @@ private struct MascotsPage: View { Text(l10n["default_mascot"]) Text(l10n["default_mascot_desc"]) } + .onChange(of: defaultSource) { _, _ in + ESP32StatePublisher.shared.notifyDirty() + } } Section { @@ -1249,6 +1252,9 @@ private struct BuddyPage: View { case .scanning: return l10n["buddy_status_scanning"] case .searchingSelected: return l10n["buddy_status_searching_selected"] case .connecting: return l10n["buddy_status_connecting"] + case .pairing: return l10n["buddy_status_pairing"] + case .pairWaitingConfirm: return l10n["buddy_status_pair_waiting_confirm"] + case .pairRejected: return l10n["buddy_status_pair_rejected"] case .connected: return l10n["buddy_status_connected"] case .reconnecting(let s): return String(format: l10n["buddy_status_reconnecting"], s) } @@ -1258,10 +1264,11 @@ private struct BuddyPage: View { _ = refreshTick switch bridge.status { case .connected: return .green - case .scanning, .connecting, .searchingSelected: return .orange + case .scanning, .connecting, .searchingSelected, .pairing: return .orange + case .pairWaitingConfirm: return .blue case .reconnecting: return .yellow case .off, .noSelection: return .secondary - case .poweredOff: return .red + case .poweredOff, .pairRejected: return .red } } @@ -1295,6 +1302,26 @@ private struct BuddyPage: View { .truncationMode(.tail) } + if bridge.status == .pairWaitingConfirm { + HStack(spacing: 6) { + Image(systemName: "hand.tap.fill") + .foregroundStyle(.blue) + Text(l10n["buddy_pair_confirm_hint"]) + .font(.caption) + .foregroundStyle(.blue) + } + } + + if bridge.status == .pairRejected { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(bridge.lastError ?? l10n["buddy_pair_rejected_hint"]) + .font(.caption) + .foregroundStyle(.red) + } + } + Button { bridge.stop() if enabled { bridge.start() } diff --git a/Sources/CodeIslandCore/ESP32Protocol.swift b/Sources/CodeIslandCore/ESP32Protocol.swift index 3facda5b..c3049967 100644 --- a/Sources/CodeIslandCore/ESP32Protocol.swift +++ b/Sources/CodeIslandCore/ESP32Protocol.swift @@ -33,9 +33,28 @@ import Foundation /// byte[0] = 0xFD /// byte[1] = orientation (0=up, 1=down) /// +/// Downlink pair request frame (7 bytes): +/// byte[0] = 0xE0 +/// byte[1..6] = hostId (6-byte stable identifier for this Mac) +/// +/// Downlink unpair frame (7 bytes): +/// byte[0] = 0xE1 +/// byte[1..6] = hostId +/// /// Uplink (button notify / notification action): /// 1 byte = currently displayed mascot sourceId (focus request), or -/// 1 byte = control opcode (approve / deny / skip). +/// 1 byte = control opcode (approve / deny / skip), or +/// 1 byte = pairing response (0xE0 accepted, 0xE1 rejected, 0xE2 pending). +/// +/// **Pairing security model:** +/// The application-layer pairing is NOT a cryptographic authentication mechanism. +/// BLE link-layer encryption (if configured) provides transport security. The 6-byte +/// `hostId` is a random stable identifier used solely to distinguish multiple Macs +/// attempting to drive the same Buddy — it prevents accidental conflicts in shared +/// office environments but does not resist an active attacker who can sniff BLE +/// traffic and replay the hostId. Physical confirmation (BOOT button press) is +/// required for initial pairing, providing a weak form of user-intent verification. +/// This is appropriate for a developer desk toy; do not rely on it for access control. /// /// Buddy firmware exits AGENT mode after 60 s with no writes, so the host /// should resend the current frame periodically (≥ every 30 s, 5 s is the @@ -56,9 +75,32 @@ public enum ESP32Protocol { public static let orientationFrameMarker: UInt8 = 0xFD public static let workspaceFrameMarker: UInt8 = 0xFC public static let messagePreviewFrameMarker: UInt8 = 0xFB + public static let modelFrameMarker: UInt8 = 0xF9 + public static let maxModelNameBytes = 18 + public static let statsFrameMarker: UInt8 = 0xFA + public static let subagentFrameMarker: UInt8 = 0xF8 + public static let eventFrameMarker: UInt8 = 0xF7 + public static let timeHintFrameMarker: UInt8 = 0xF6 + public static let toolHistoryFrameMarker: UInt8 = 0xF5 public static let approveCurrentPermissionMarker: UInt8 = 0xF0 public static let denyCurrentPermissionMarker: UInt8 = 0xF1 public static let skipCurrentQuestionMarker: UInt8 = 0xF2 + + // Pairing protocol (application-layer handshake over BLE write/notify). + // Reserved range: 0xE0–0xEF is reserved for pairing opcodes on both + // downlink and uplink. MascotID raw values MUST stay below 0xE0 to + // avoid collision with pair response parsing in BuddyUplinkEvent. + public static let pairRequestMarker: UInt8 = 0xE0 + public static let unpairMarker: UInt8 = 0xE1 + public static let hostIdLength = 6 + public static let pairRequestFrameBytes = 1 + hostIdLength // 0xE0 + 6-byte hostId + public static let unpairFrameBytes = 1 + hostIdLength // 0xE1 + 6-byte hostId + // Uplink pairing responses (Buddy → Mac via notify) + public static let pairAcceptedMarker: UInt8 = 0xE0 + public static let pairRejectedMarker: UInt8 = 0xE1 + public static let pairPendingMarker: UInt8 = 0xE2 + public static let pairConfirmTimeoutSeconds: Int = 30 + public static let minBrightnessPercent: UInt8 = 10 public static let maxBrightnessPercent: UInt8 = 100 public static let defaultBrightnessPercent: UInt8 = 70 @@ -80,12 +122,26 @@ public enum BuddyControlCommand: UInt8, Equatable, Sendable { case skipCurrentQuestion = 0xF2 } +public enum BuddyPairResponse: UInt8, Equatable, Sendable { + case accepted = 0xE0 + case rejected = 0xE1 + case pending = 0xE2 +} + public enum BuddyUplinkEvent: Equatable, Sendable { case focus(MascotID) case command(BuddyControlCommand) + case pairResponse(BuddyPairResponse) public init?(payload: Data) { guard let first = payload.first else { return nil } + // Pair responses (0xE0–0xE2) are checked first. This is safe because + // MascotID raw values are 0..15, well below the 0xE0 reserved range. + // If MascotID ever grows past 0xDF this ordering MUST be revisited. + if let pairResp = BuddyPairResponse(rawValue: first) { + self = .pairResponse(pairResp) + return + } if let mascot = MascotID(rawValue: first) { self = .focus(mascot) return @@ -128,6 +184,7 @@ public enum BuddyScreenOrientation: String, CaseIterable, Identifiable, Sendable } /// Mascot slot on Buddy (0..15). The index is the on-wire `sourceId`. +/// Raw values MUST stay below 0xE0 (see ESP32Protocol pairing reserved range). public enum MascotID: UInt8, CaseIterable, Sendable { case claude = 0 case codex = 1 @@ -200,7 +257,7 @@ public enum MascotID: UInt8, CaseIterable, Sendable { } /// On-wire status code. Matches the Buddy firmware's `statusToScene` table: -/// 0 → SLEEP, 1/2 → WORK (toolName is drawn), 3/4 → ALERT. +/// 0 → SLEEP, 1/2 → WORK (toolName is drawn), 3 → ALERT, 4 → QUESTION. public enum MascotStatusCode: UInt8, Sendable { case idle = 0 case processing = 1 @@ -361,3 +418,153 @@ public struct BuddyScreenOrientationPayload: Equatable, Sendable { Data([ESP32Protocol.orientationFrameMarker, orientation.wireValue]) } } + +/// Model info frame for Buddy (0xF9). +public struct BuddyModelPayload: Equatable, Sendable { + public let modelName: String? + + public init(modelName: String?) { self.modelName = modelName } + + public func encode() -> Data { + var data = Data() + data.append(ESP32Protocol.modelFrameMarker) + let bytes = Array((modelName ?? "").utf8.prefix(ESP32Protocol.maxModelNameBytes)) + data.append(UInt8(bytes.count)) + data.append(contentsOf: bytes) + return data + } +} + +/// Session stats frame for Buddy (0xFA). +public struct BuddyStatsPayload: Equatable, Sendable { + public let activeSessionCount: UInt8 + public let totalSessionCount: UInt8 + public let toolCallCount: UInt16 + public let sessionDurationMinutes: UInt8 + + public init(activeSessionCount: Int, totalSessionCount: Int, + toolCallCount: Int, sessionDurationMinutes: Int) { + self.activeSessionCount = UInt8(min(255, max(0, activeSessionCount))) + self.totalSessionCount = UInt8(min(255, max(0, totalSessionCount))) + self.toolCallCount = UInt16(min(65535, max(0, toolCallCount))) + self.sessionDurationMinutes = UInt8(min(255, max(0, sessionDurationMinutes))) + } + + public func encode() -> Data { + Data([ + ESP32Protocol.statsFrameMarker, + activeSessionCount, + totalSessionCount, + UInt8(toolCallCount >> 8), + UInt8(toolCallCount & 0xFF), + sessionDurationMinutes, + ]) + } +} + +/// Subagent count frame for Buddy (0xF8). +public struct BuddySubagentPayload: Equatable, Sendable { + public let count: UInt8 + + public init(count: Int) { + self.count = UInt8(min(15, max(0, count))) + } + + public func encode() -> Data { + Data([ESP32Protocol.subagentFrameMarker, count]) + } +} + +/// Event frame for Buddy (0xF7) — triggers transient animations. +public struct BuddyEventPayload: Equatable, Sendable { + public let eventId: UInt8 + + public static let start = BuddyEventPayload(eventId: 0) + public static let complete = BuddyEventPayload(eventId: 1) + public static let error = BuddyEventPayload(eventId: 2) + public static let approval = BuddyEventPayload(eventId: 3) + public static let submit = BuddyEventPayload(eventId: 4) + + public init(eventId: UInt8) { self.eventId = eventId } + + public func encode() -> Data { + Data([ESP32Protocol.eventFrameMarker, eventId]) + } +} + +/// Time hint frame for Buddy (0xF6). +public struct BuddyTimeHintPayload: Equatable, Sendable { + public let hour: UInt8 + + public init(hour: Int) { + self.hour = UInt8(min(23, max(0, hour))) + } + + public func encode() -> Data { + Data([ESP32Protocol.timeHintFrameMarker, hour]) + } +} + +/// Tool history entry frame for Buddy (0xF5). +public struct BuddyToolHistoryPayload: Equatable, Sendable { + public let index: UInt8 + public let success: Bool + public let toolName: String + + public init(index: Int, success: Bool, toolName: String) { + self.index = UInt8(min(255, max(0, index))) + self.success = success + self.toolName = toolName + } + + public func encode() -> Data { + var data = Data() + data.append(ESP32Protocol.toolHistoryFrameMarker) + data.append(index) + let nameBytes = Array(toolName.utf8.prefix(11)) + let flags = (success ? 0x80 : 0x00) | UInt8(nameBytes.count) + data.append(flags) + data.append(contentsOf: nameBytes) + return data + } +} + +/// Pair request frame (Mac → Buddy, 0xE0). +/// `hostId` is a stable 6-byte identifier unique to this Mac instance. +public struct BuddyPairRequestPayload: Equatable, Sendable { + public let hostId: Data + + public init(hostId: Data) { + self.hostId = hostId.prefix(ESP32Protocol.hostIdLength) + } + + public func encode() -> Data { + var data = Data() + data.reserveCapacity(ESP32Protocol.pairRequestFrameBytes) + data.append(ESP32Protocol.pairRequestMarker) + var padded = hostId.prefix(ESP32Protocol.hostIdLength) + while padded.count < ESP32Protocol.hostIdLength { padded.append(0) } + data.append(padded) + return data + } +} + +/// Unpair frame (Mac → Buddy, 0xE1). +/// Sent before disconnecting when the user forgets a Buddy. +public struct BuddyUnpairPayload: Equatable, Sendable { + public let hostId: Data + + public init(hostId: Data) { + self.hostId = hostId.prefix(ESP32Protocol.hostIdLength) + } + + public func encode() -> Data { + var data = Data() + data.reserveCapacity(ESP32Protocol.unpairFrameBytes) + data.append(ESP32Protocol.unpairMarker) + var padded = hostId.prefix(ESP32Protocol.hostIdLength) + while padded.count < ESP32Protocol.hostIdLength { padded.append(0) } + data.append(padded) + return data + } +} diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index 0991b1fb..c16d8768 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -43,6 +43,7 @@ public struct SessionSnapshot: Sendable { public var model: String? public var permissionMode: String? public var toolHistory: [ToolHistoryEntry] = [] + public var totalToolCallCount: Int = 0 public var subagents: [String: SubagentState] = [:] public var startTime: Date = Date() public var lastUserPrompt: String? @@ -186,6 +187,7 @@ public struct SessionSnapshot: Sendable { } public mutating func recordTool(_ tool: String, description: String?, success: Bool, agentType: String?, maxHistory: Int) { + totalToolCallCount += 1 let entry = ToolHistoryEntry(tool: tool, description: description, timestamp: Date(), success: success, agentType: agentType) toolHistory.append(entry) if toolHistory.count > maxHistory { diff --git a/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift b/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift index d4bc13a3..ee8ac6bc 100644 --- a/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift +++ b/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift @@ -160,4 +160,166 @@ final class ESP32ProtocolTests: XCTestCase { } } } + + // MARK: - Model frame encoding + + func testEncodeModelFrame() { + let frame = BuddyModelPayload(modelName: "opus") + let data = frame.encode() + XCTAssertEqual(data[0], ESP32Protocol.modelFrameMarker) + XCTAssertEqual(data[1], 4) + XCTAssertEqual(String(data: data.subdata(in: 2.. **关于分区方案与 OTA**:选择 "Huge APP (3MB No OTA)" 是因为固件体积较大,需要完整的 3MB APP 分区。固件中的 `ArduinoOTA` 功能是实验性的 WiFi network OTA,通过单分区直接覆写实现(非 ESP32 原生双分区 OTA),仅在通过 BLE 下发 WiFi 凭据后才会激活。生产环境建议仍通过 USB 烧录。 + > 在 Port 列表里看不到 `/dev/cu.usbmodem*`?大概率是数据线只能充电。ESP32-C6 走原生 USB,macOS 免驱,换一根能传数据的线即可。 --- diff --git a/hardware/hardware.ino b/hardware/hardware.ino index 43109756..f72da503 100644 --- a/hardware/hardware.ino +++ b/hardware/hardware.ino @@ -7,6 +7,11 @@ #include #include #include +#include +#ifdef BUDDY_OTA_ENABLED +#include +#include +#endif // ========================================================= // Buddy — Multi-mascot Bluetooth Pet @@ -71,6 +76,17 @@ static char bleDeviceName[BLE_DEVICE_NAME_LEN] = "Buddy"; #define BUDDY_SCREEN_UP 0 #define BUDDY_SCREEN_DOWN 1 +// --- Pairing protocol --- +#define PAIR_REQUEST_MARKER 0xE0 +#define UNPAIR_MARKER 0xE1 +#define PAIR_ACCEPTED_MARKER 0xE0 +#define PAIR_REJECTED_MARKER 0xE1 +#define PAIR_PENDING_MARKER 0xE2 +#define HOST_ID_LENGTH 6 +#define PAIR_CONFIRM_TIMEOUT_MS 30000UL +#define PAIR_REJECT_DELAY_MS 500UL +#define SUPER_LONG_PRESS_MS 3000 + // QR code for https://github.com/wxtsky/CodeIsland (version 3, ECC M, border 2). #define CODEISLAND_QR_SIZE 33 #define CODEISLAND_QR_SCALE 4 @@ -119,17 +135,94 @@ volatile uint8_t buddyBrightnessPercent = BUDDY_BRIGHTNESS_DEFAULT_PERCENT; volatile uint8_t buddyScreenOrientation = BUDDY_SCREEN_UP; volatile bool buddyOrientationDirty = false; char bleToolName[18] = {0}; +char bleWorkspaceName[20] = {0}; +char bleModelName[20] = {0}; BLECharacteristic* pNotifyChar = nullptr; portMUX_TYPE bleMux = portMUX_INITIALIZER_UNLOCKED; +// --- Session stats from BLE --- +uint8_t bleActiveSessionCount = 0; +uint8_t bleTotalSessionCount = 0; +uint16_t bleToolCallCount = 0; +uint8_t bleSessionDurationMin = 0; + +// --- Subagent count --- +uint8_t bleSubagentCount = 0; + +// --- Progress-aware animation --- +volatile unsigned long lastBleWriteTime = 0; +volatile unsigned long bleWriteInterval = 5000; + +// --- Transient animation --- +enum TransientAnim { ANIM_NONE, ANIM_CELEBRATE, ANIM_FRUSTRATED }; +TransientAnim pendingAnim = ANIM_NONE; +unsigned long animStartTime = 0; +#define ANIM_DURATION_MS 2000 + +// --- Tool history --- +#define MAX_TOOL_HISTORY 10 +struct ToolHistEntry { + char name[12]; + bool success; +}; +ToolHistEntry toolHistory[MAX_TOOL_HISTORY]; +uint8_t toolHistCount = 0; + +// --- Activity heatmap --- +uint8_t heatmap[24] = {0}; +uint8_t heatmapSlot = 0; +uint8_t bleCurrentHour = 255; + +// --- Global bored flag (checked by mascot sleep functions) --- +bool globalBored = false; +float globalBoredEyeOffsetX = 0.0f; + +// --- Activity-based time scale for work animations --- +float globalWorkTimeScale = 1.0f; + +// --- NVS persistence --- +Preferences prefs; +unsigned long lastNvsSave = 0; +bool nvsDirty = false; +#define NVS_DEBOUNCE_MS 5000 + +#ifdef BUDDY_OTA_ENABLED +// --- OTA state --- +bool otaEnabled = false; +volatile bool otaPending = false; +char otaSsid[33] = {0}; +char otaPassword[65] = {0}; +#endif + +// --- Dirty flags for partial screen refresh --- +volatile bool headerDirty = true; +volatile bool infoDirty = true; +// Track previous values for dirty detection +uint8_t prevSourceId = 0xFF; +uint8_t prevStatusId = 0xFF; +char prevToolName[18] = {0}; +char prevWorkspaceName[20] = {0}; + // --- Scenes --- -enum Scene { SCENE_SLEEP, SCENE_WORK, SCENE_ALERT, SCENE_COUNT }; +enum Scene { SCENE_SLEEP, SCENE_WORK, SCENE_ALERT, SCENE_QUESTION, SCENE_COUNT }; // --- App mode --- -enum AppMode { MODE_ONBOARD, MODE_DEMO, MODE_AGENT }; +enum AppMode { MODE_ONBOARD, MODE_DEMO, MODE_AGENT, MODE_PAIR_CONFIRM }; volatile AppMode appMode = MODE_ONBOARD; bool hasEverConnected = false; +// --- Pairing state --- +// These are accessed from both BLE callbacks (may run on BLE task) and loop(). +// Use volatile + bleMux for compound reads/writes. +volatile bool isPaired = false; +uint8_t pairedHostId[HOST_ID_LENGTH] = {0}; +uint8_t pendingHostId[HOST_ID_LENGTH] = {0}; +volatile unsigned long pairRequestTime = 0; +volatile bool pairRejectPending = false; +volatile unsigned long pairRejectTime = 0; +volatile bool pairAuthenticated = false; +bool superLongPressFired = false; + // --- Include all mascot headers --- #include "mascot_common.h" #include "mascot_clawd.h" @@ -156,28 +249,29 @@ struct Mascot { DrawFunc sleep; DrawFunc work; DrawFunc alert; + DrawFunc question; const char* name; }; #define NUM_MASCOTS 16 Mascot mascots[NUM_MASCOTS] = { - { clawdSleep, clawdWork, clawdAlert, "Claude" }, // 0 - { dexSleep, dexWork, dexAlert, "Codex" }, // 1 - { geminiSleep, geminiWork, geminiAlert, "Gemini" }, // 2 - { cursorSleep, cursorWork, cursorAlert, "Cursor" }, // 3 - { copilotSleep, copilotWork, copilotAlert, "Copilot" }, // 4 - { traeSleep, traeWork, traeAlert, "Trae" }, // 5 - { qoderSleep, qoderWork, qoderAlert, "Qoder" }, // 6 - { droidSleep, droidWork, droidAlert, "Factory" }, // 7 - { buddySleep, buddyWork, buddyAlert, "CodeBuddy" }, // 8 - { stepfunSleep, stepfunWork, stepfunAlert, "StepFun" }, // 9 - { opencodeSleep, opencodeWork, opencodeAlert, "OpenCode" }, // 10 - { qwenSleep, qwenWork, qwenAlert, "Qwen" }, // 11 - { antigravSleep, antigravWork, antigravAlert, "AntiGravity" }, // 12 - { workbuddySleep, workbuddyWork, workbuddyAlert, "WorkBuddy" }, // 13 - { hermesSleep, hermesWork, hermesAlert, "Hermes" }, // 14 - { kimiSleep, kimiWork, kimiAlert, "Kimi" }, // 15 + { clawdSleep, clawdWork, clawdAlert, clawdQuestion, "Claude" }, // 0 + { dexSleep, dexWork, dexAlert, dexQuestion, "Codex" }, // 1 + { geminiSleep, geminiWork, geminiAlert, geminiQuestion, "Gemini" }, // 2 + { cursorSleep, cursorWork, cursorAlert, cursorQuestion, "Cursor" }, // 3 + { copilotSleep, copilotWork, copilotAlert, copilotQuestion, "Copilot" }, // 4 + { traeSleep, traeWork, traeAlert, traeQuestion, "Trae" }, // 5 + { qoderSleep, qoderWork, qoderAlert, qoderQuestion, "Qoder" }, // 6 + { droidSleep, droidWork, droidAlert, droidQuestion, "Factory" }, // 7 + { buddySleep, buddyWork, buddyAlert, buddyQuestion, "CodeBuddy" }, // 8 + { stepfunSleep, stepfunWork, stepfunAlert, stepfunQuestion, "StepFun" }, // 9 + { opencodeSleep, opencodeWork, opencodeAlert, opencodeQuestion, "OpenCode" }, // 10 + { qwenSleep, qwenWork, qwenAlert, qwenQuestion, "Qwen" }, // 11 + { antigravSleep, antigravWork, antigravAlert, antigravQuestion, "AntiGravity" }, // 12 + { workbuddySleep, workbuddyWork, workbuddyAlert, workbuddyQuestion, "WorkBuddy" }, // 13 + { hermesSleep, hermesWork, hermesAlert, hermesQuestion, "Hermes" }, // 14 + { kimiSleep, kimiWork, kimiAlert, kimiQuestion, "Kimi" }, // 15 }; // --- Mode --- @@ -200,19 +294,21 @@ float currentFps = 0; static const char* sceneStr(Scene s) { switch (s) { - case SCENE_SLEEP: return "SLEEP"; - case SCENE_WORK: return "WORK"; - case SCENE_ALERT: return "ALERT"; - default: return "?"; + case SCENE_SLEEP: return "SLEEP"; + case SCENE_WORK: return "WORK"; + case SCENE_ALERT: return "ALERT"; + case SCENE_QUESTION: return "QUESTION"; + default: return "?"; } } static const char* appModeStr(AppMode m) { switch (m) { - case MODE_ONBOARD: return "ONBOARD"; - case MODE_DEMO: return "DEMO"; - case MODE_AGENT: return "AGENT"; - default: return "?"; + case MODE_ONBOARD: return "ONBOARD"; + case MODE_DEMO: return "DEMO"; + case MODE_AGENT: return "AGENT"; + case MODE_PAIR_CONFIRM: return "PAIR_CONFIRM"; + default: return "?"; } } @@ -321,16 +417,51 @@ int pollButton(unsigned long now) { class ServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* pServer) override { bleConnected = true; - hasEverConnected = true; - Serial.println("[BLE] Connected"); + pairAuthenticated = false; + Serial.println("[BLE] Connected, waiting for pair handshake..."); } void onDisconnect(BLEServer* pServer) override { bleConnected = false; + pairAuthenticated = false; + if (appMode == MODE_PAIR_CONFIRM) { + appMode = MODE_ONBOARD; + Serial.println("[BLE] Disconnected during pairing, back to ONBOARD"); + } Serial.println("[BLE] Disconnected, re-advertising..."); BLEDevice::startAdvertising(); } }; +// --- Pairing helpers (called from BLE callback context) --- +static void sendPairNotify(uint8_t marker) { + if (pNotifyChar) { + pNotifyChar->setValue(&marker, 1); + pNotifyChar->notify(); + } +} + +static void savePairedHost(const uint8_t* hostId) { + memcpy(pairedHostId, hostId, HOST_ID_LENGTH); + isPaired = true; + prefs.putBytes("ph", pairedHostId, HOST_ID_LENGTH); + prefs.putBool("ps", true); + Serial.printf("[PAIR] Saved paired host: %02X%02X%02X%02X%02X%02X\n", + pairedHostId[0], pairedHostId[1], pairedHostId[2], + pairedHostId[3], pairedHostId[4], pairedHostId[5]); +} + +static void clearPairedHost() { + memset(pairedHostId, 0, HOST_ID_LENGTH); + isPaired = false; + prefs.remove("ph"); + prefs.putBool("ps", false); + Serial.println("[PAIR] Cleared paired host"); +} + +static bool hostIdMatches(const uint8_t* a, const uint8_t* b) { + return memcmp(a, b, HOST_ID_LENGTH) == 0; +} + class CharCallbacks : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic* pChar) override { uint8_t* data = pChar->getData(); @@ -339,12 +470,89 @@ class CharCallbacks : public BLECharacteristicCallbacks { for (size_t i = 0; i < len && i < 24; i++) Serial.printf(" %02X", data[i]); Serial.println(); + // --- Pair Request (0xE0) — always processed regardless of auth state --- + if (len >= 1 + HOST_ID_LENGTH && data[0] == PAIR_REQUEST_MARKER) { + uint8_t incomingId[HOST_ID_LENGTH]; + memcpy(incomingId, data + 1, HOST_ID_LENGTH); + Serial.printf("[PAIR] Request from host: %02X%02X%02X%02X%02X%02X\n", + incomingId[0], incomingId[1], incomingId[2], + incomingId[3], incomingId[4], incomingId[5]); + + portENTER_CRITICAL(&bleMux); + bool localIsPaired = isPaired; + bool hostMatch = localIsPaired && hostIdMatches(pairedHostId, incomingId); + bool alreadyConfirming = (appMode == MODE_PAIR_CONFIRM); + portEXIT_CRITICAL(&bleMux); + + if (hostMatch) { + portENTER_CRITICAL(&bleMux); + pairAuthenticated = true; + hasEverConnected = true; + appMode = MODE_AGENT; + lastBleData = millis(); + portEXIT_CRITICAL(&bleMux); + sendPairNotify(PAIR_ACCEPTED_MARKER); + Serial.println("[PAIR] Accepted (known host)"); + } else if (!localIsPaired && !alreadyConfirming) { + portENTER_CRITICAL(&bleMux); + memcpy(pendingHostId, incomingId, HOST_ID_LENGTH); + pairRequestTime = millis(); + appMode = MODE_PAIR_CONFIRM; + portEXIT_CRITICAL(&bleMux); + sendPairNotify(PAIR_PENDING_MARKER); + Serial.println("[PAIR] Pending — waiting for button confirmation"); + } else if (!localIsPaired && alreadyConfirming) { + sendPairNotify(PAIR_REJECTED_MARKER); + pairRejectPending = true; + pairRejectTime = millis(); + Serial.println("[PAIR] Rejected (already confirming another host)"); + } else { + sendPairNotify(PAIR_REJECTED_MARKER); + pairRejectPending = true; + pairRejectTime = millis(); + Serial.println("[PAIR] Rejected (already paired to different host)"); + } + return; + } + + // --- Unpair (0xE1) — always processed regardless of auth state --- + if (len >= 1 + HOST_ID_LENGTH && data[0] == UNPAIR_MARKER) { + uint8_t incomingId[HOST_ID_LENGTH]; + memcpy(incomingId, data + 1, HOST_ID_LENGTH); + Serial.printf("[PAIR] Unpair from host: %02X%02X%02X%02X%02X%02X\n", + incomingId[0], incomingId[1], incomingId[2], + incomingId[3], incomingId[4], incomingId[5]); + + portENTER_CRITICAL(&bleMux); + bool shouldUnpair = isPaired && hostIdMatches(pairedHostId, incomingId); + portEXIT_CRITICAL(&bleMux); + + if (shouldUnpair) { + clearPairedHost(); + portENTER_CRITICAL(&bleMux); + pairAuthenticated = false; + appMode = MODE_ONBOARD; + portEXIT_CRITICAL(&bleMux); + Serial.println("[PAIR] Unpaired successfully"); + } else { + Serial.println("[PAIR] Unpair ignored (host mismatch or not paired)"); + } + return; + } + + // --- All other frames require successful pairing --- + if (!pairAuthenticated) { + Serial.println("[BLE] WARN: data frame rejected (not paired/authenticated)"); + return; + } + if (len == 2 && data[0] == BUDDY_BRIGHTNESS_FRAME) { uint8_t percent = clampBuddyBrightness(data[1]); portENTER_CRITICAL(&bleMux); buddyBrightnessPercent = percent; portEXIT_CRITICAL(&bleMux); lastInteraction = millis(); + nvsDirty = true; Serial.printf("[BLE] Brightness config: %d%%\n", percent); return; } @@ -355,6 +563,7 @@ class CharCallbacks : public BLECharacteristicCallbacks { if (buddyScreenOrientation != orientation) { buddyScreenOrientation = orientation; buddyOrientationDirty = true; + nvsDirty = true; } portEXIT_CRITICAL(&bleMux); lastInteraction = millis(); @@ -362,12 +571,174 @@ class CharCallbacks : public BLECharacteristicCallbacks { return; } + // Workspace frame (0xFC) + if (len >= 2 && data[0] == 0xFC) { + uint8_t wsLen = data[1]; + if (wsLen > 18) wsLen = 18; + portENTER_CRITICAL(&bleMux); + memset(bleWorkspaceName, 0, sizeof(bleWorkspaceName)); + if (wsLen > 0 && len >= 2u + wsLen) { + memcpy(bleWorkspaceName, data + 2, wsLen); + } + bleWorkspaceName[wsLen] = '\0'; + lastBleData = millis(); + portEXIT_CRITICAL(&bleMux); + infoDirty = true; + Serial.printf("[BLE] Workspace: \"%s\"\n", bleWorkspaceName); + return; + } + + // Model info frame (0xF9) + if (len >= 2 && data[0] == 0xF9) { + uint8_t mLen = data[1]; + if (mLen > 18) mLen = 18; + portENTER_CRITICAL(&bleMux); + memset(bleModelName, 0, sizeof(bleModelName)); + if (mLen > 0 && len >= 2u + mLen) memcpy(bleModelName, data + 2, mLen); + headerDirty = true; + lastBleData = millis(); + portEXIT_CRITICAL(&bleMux); + Serial.printf("[BLE] Model: \"%s\"\n", bleModelName); + return; + } + + // Session stats frame (0xFA) + if (len >= 6 && data[0] == 0xFA) { + portENTER_CRITICAL(&bleMux); + bleActiveSessionCount = data[1]; + bleTotalSessionCount = data[2]; + uint16_t newToolCount = ((uint16_t)data[3] << 8) | data[4]; + if (newToolCount > bleToolCallCount) { + uint16_t delta = newToolCount - bleToolCallCount; + uint16_t newVal = heatmap[heatmapSlot] + delta; + heatmap[heatmapSlot] = (newVal > 255) ? 255 : (uint8_t)newVal; + } + bleToolCallCount = newToolCount; + bleSessionDurationMin = data[5]; + lastBleData = millis(); + portEXIT_CRITICAL(&bleMux); + Serial.printf("[BLE] Stats: sessions=%d/%d tools=%d duration=%dm\n", + bleActiveSessionCount, bleTotalSessionCount, bleToolCallCount, bleSessionDurationMin); + return; + } + + // Subagent count frame (0xF8) + if (len >= 2 && data[0] == 0xF8) { + portENTER_CRITICAL(&bleMux); + bleSubagentCount = data[1]; + lastBleData = millis(); + portEXIT_CRITICAL(&bleMux); + Serial.printf("[BLE] Subagents: %d\n", bleSubagentCount); + return; + } + + // Event frame (0xF7) — transient animations + if (len >= 2 && data[0] == 0xF7) { + uint8_t eventId = data[1]; + portENTER_CRITICAL(&bleMux); + lastBleData = millis(); + if (eventId == 1) { pendingAnim = ANIM_CELEBRATE; animStartTime = millis(); } + else if (eventId == 2) { pendingAnim = ANIM_FRUSTRATED; animStartTime = millis(); } + portEXIT_CRITICAL(&bleMux); + Serial.printf("[BLE] Event: %d\n", eventId); + return; + } + + // Time hint frame (0xF6) + if (len >= 2 && data[0] == 0xF6) { + uint8_t newHour = data[1]; + if (bleCurrentHour != 255 && newHour != bleCurrentHour) { + heatmapSlot = newHour % 24; + heatmap[heatmapSlot] = 0; + } + bleCurrentHour = newHour; + lastBleData = millis(); + Serial.printf("[BLE] Hour: %d\n", bleCurrentHour); + return; + } + +#ifdef BUDDY_OTA_ENABLED + // OTA SSID frame (0xF4) + if (len >= 2 && data[0] == 0xF4) { + uint8_t ssidLen = data[1]; + if (ssidLen > 31) ssidLen = 31; + memset(otaSsid, 0, sizeof(otaSsid)); + if (ssidLen > 0 && len >= 2u + ssidLen) { + memcpy(otaSsid, data + 2, ssidLen); + } + lastBleData = millis(); + #ifdef DEBUG + Serial.printf("[BLE] OTA SSID: \"%s\"\n", otaSsid); + #else + Serial.println("[BLE] OTA SSID received"); + #endif + return; + } + + // OTA Password frame (0xF3) + if (len >= 3 && data[0] == 0xF3) { + uint8_t chunkIdx = data[1]; + uint8_t chunkLen = data[2]; + if (chunkLen > 17) chunkLen = 17; + if (chunkIdx == 0) memset(otaPassword, 0, sizeof(otaPassword)); + size_t curLen = strlen(otaPassword); + if (curLen + chunkLen < sizeof(otaPassword) - 1 && len >= 3u + chunkLen) { + memcpy(otaPassword + curLen, data + 3, chunkLen); + otaPassword[curLen + chunkLen] = '\0'; + } + lastBleData = millis(); + #ifdef DEBUG + Serial.printf("[BLE] OTA password chunk %d (%d bytes, total=%zu)\n", chunkIdx, chunkLen, strlen(otaPassword)); + #endif + if (otaSsid[0] != '\0' && otaPassword[0] != '\0' && !otaEnabled) { + otaPending = true; + } + return; + } +#endif + + // Message preview frame (0xFB) + if (len >= 4 && data[0] == 0xFB) { + uint8_t msgIdx = data[1]; + uint8_t msgTotal = data[2]; + portENTER_CRITICAL(&bleMux); + lastBleData = millis(); + portEXIT_CRITICAL(&bleMux); + Serial.printf("[BLE] MsgPreview: idx=%d/%d\n", msgIdx, msgTotal); + return; + } + + // Tool history frame (0xF5) + if (len >= 3 && data[0] == 0xF5) { + uint8_t entryIdx = data[1]; + uint8_t flagsByte = data[2]; + bool success = (flagsByte & 0x80) != 0; + uint8_t nameLen = flagsByte & 0x7F; + if (nameLen > 11) nameLen = 11; + portENTER_CRITICAL(&bleMux); + if (entryIdx == 0) toolHistCount = 0; + if (toolHistCount < MAX_TOOL_HISTORY) { + ToolHistEntry& entry = toolHistory[toolHistCount]; + memset(entry.name, 0, sizeof(entry.name)); + if (nameLen > 0 && len >= 3u + nameLen) { + memcpy(entry.name, data + 3, nameLen); + } + entry.success = success; + toolHistCount++; + } + lastBleData = millis(); + portEXIT_CRITICAL(&bleMux); + return; + } + if (len < 3) { Serial.println("[BLE] WARN: payload too short (<3), ignored"); return; } portENTER_CRITICAL(&bleMux); + if (bleSourceId != data[0]) headerDirty = true; + if (bleStatusId != data[1]) infoDirty = true; bleSourceId = data[0]; bleStatusId = data[1]; uint8_t toolLen = data[2]; @@ -379,6 +750,11 @@ class CharCallbacks : public BLECharacteristicCallbacks { bleToolName[toolLen] = '\0'; lastBleData = millis(); appMode = MODE_AGENT; + unsigned long now_write = millis(); + if (lastBleWriteTime > 0) { + bleWriteInterval = now_write - lastBleWriteTime; + } + lastBleWriteTime = now_write; portEXIT_CRITICAL(&bleMux); const char* srcName = (bleSourceId < NUM_MASCOTS) ? mascots[bleSourceId].name : "?"; @@ -394,13 +770,13 @@ Scene statusToScene(uint8_t status) { case 1: return SCENE_WORK; // processing case 2: return SCENE_WORK; // running case 3: return SCENE_ALERT; // waitingApproval - case 4: return SCENE_ALERT; // waitingQuestion + case 4: return SCENE_QUESTION; // waitingQuestion default: return SCENE_SLEEP; } } // --- Draw tool name label --- -void drawToolLabel() { +void drawToolLabel(int baseY = 240) { char localTool[18]; uint8_t localStatus; portENTER_CRITICAL(&bleMux); @@ -408,37 +784,208 @@ void drawToolLabel() { localStatus = bleStatusId; portEXIT_CRITICAL(&bleMux); if (localTool[0] == '\0') return; - if (localStatus < 1 || localStatus > 2) return; + if (localStatus < 1 || localStatus > 4) return; + + ToolIcon icon = classifyTool(localTool); + int16_t tw = strlen(localTool) * 12; + int16_t totalW = tw + (icon != ICON_NONE ? 12 : 0); + int16_t startX = (LCD_W - totalW) / 2; + + if (icon != ICON_NONE) { + drawToolIcon(icon, startX, baseY + 2, RGB565(80, 180, 220)); + startX += 12; + } + gfx->setTextColor(RGB565(120, 120, 130)); gfx->setTextSize(2); - int16_t tw = strlen(localTool) * 12; - gfx->setCursor((LCD_W - tw) / 2, sy(19.35f)); + gfx->setCursor(startX, baseY); gfx->print(localTool); } -// --- Draw mascot name label (below mascot, larger font) --- +// --- Draw mascot name label (header area) --- void drawMascotName(uint8_t idx) { if (idx >= NUM_MASCOTS) return; const char* name = mascots[idx].name; - gfx->setTextSize(2); - int16_t tw = strlen(name) * 12; - gfx->setTextColor(RGB565(160, 160, 170)); - gfx->setCursor((LCD_W - tw) / 2, sy(17.45f)); - gfx->print(name); + + char localModel[20]; + portENTER_CRITICAL(&bleMux); + memcpy(localModel, bleModelName, sizeof(localModel)); + portEXIT_CRITICAL(&bleMux); + + if (localModel[0] != '\0') { + char headerBuf[40]; + snprintf(headerBuf, sizeof(headerBuf), "%s · %s", name, localModel); + int16_t tw = strlen(headerBuf) * 6; + gfx->setTextSize(1); + gfx->setTextColor(RGB565(160, 160, 170)); + gfx->setCursor((LCD_W - tw) / 2, 8); + gfx->print(headerBuf); + } else { + gfx->setTextSize(2); + int16_t tw = strlen(name) * 12; + gfx->setTextColor(RGB565(160, 160, 170)); + gfx->setCursor((LCD_W - tw) / 2, 6); + gfx->print(name); + } +} + +// --- Draw workspace label --- +void drawWorkspaceLabel(int baseY = 224) { + char localWs[20]; + portENTER_CRITICAL(&bleMux); + memcpy(localWs, bleWorkspaceName, sizeof(localWs)); + portEXIT_CRITICAL(&bleMux); + if (localWs[0] == '\0') return; + gfx->setTextColor(RGB565(80, 180, 220)); + gfx->setTextSize(1); + int16_t tw = strlen(localWs) * 6; + gfx->setCursor((LCD_W - tw) / 2, baseY); + gfx->print(localWs); +} + +// --- Draw action hints for Alert/Question scenes (y=300) --- +void drawAlertActionHints(uint8_t status) { + if (status == 3) { + drawCenteredText("Press: Allow", LCD_H - 18, 1, RGB565(50, 200, 50)); + drawCenteredText("Hold: Deny", LCD_H - 8, 1, RGB565(200, 60, 60)); + } else if (status == 4) { + drawCenteredText("Press: Open", LCD_H - 18, 1, RGB565(80, 180, 255)); + drawCenteredText("Hold: Skip", LCD_H - 8, 1, RGB565(150, 150, 160)); + } +} + +// --- Draw session stats panel (shown during idle, below workspace) --- +void drawStatsPanel() { + uint8_t actS, totS, durM; + uint16_t toolC; + portENTER_CRITICAL(&bleMux); + actS = bleActiveSessionCount; + totS = bleTotalSessionCount; + toolC = bleToolCallCount; + durM = bleSessionDurationMin; + portEXIT_CRITICAL(&bleMux); + + if (actS == 0 && totS == 0 && toolC == 0) return; + + int panelY = 236; + char buf[32]; + gfx->setTextSize(1); + gfx->setTextColor(RGB565(100, 100, 120)); + + snprintf(buf, sizeof(buf), "S:%d/%d T:%d %dm", actS, totS, toolC, durM); + int16_t tw = strlen(buf) * 6; + gfx->setCursor((LCD_W - tw) / 2, panelY); + gfx->print(buf); +} + +// --- Draw tool history timeline (shown during idle, below stats) --- +void drawToolTimeline() { + if (toolHistCount == 0) return; + int startY = 248; + gfx->setTextSize(1); + uint8_t localCount; + ToolHistEntry localHist[MAX_TOOL_HISTORY]; + portENTER_CRITICAL(&bleMux); + localCount = toolHistCount; + memcpy(localHist, toolHistory, sizeof(localHist)); + portEXIT_CRITICAL(&bleMux); + + uint8_t maxVisible = min(localCount, (uint8_t)5); + for (uint8_t i = 0; i < maxVisible; i++) { + int y = startY + i * 10; + uint16_t markCol = localHist[i].success ? RGB565(50, 200, 50) : RGB565(200, 60, 60); + gfx->fillRect(14, y + 2, 3, 3, markCol); + gfx->setTextColor(RGB565(120, 120, 140)); + gfx->setCursor(22, y); + gfx->print(localHist[i].name); + } +} + +// --- Draw heatmap bar (24h activity, bottom of idle screen) --- +void drawHeatmapBar() { + bool hasData = false; + for (int i = 0; i < 24; i++) { if (heatmap[i] > 0) { hasData = true; break; } } + if (!hasData) return; + + int barY = LCD_H - 24; + int slotW = (LCD_W - 4) / 24; + for (int i = 0; i < 24; i++) { + uint8_t val = heatmap[(heatmapSlot + 1 + i) % 24]; + float intensity = val / 255.0f; + uint8_t r = (uint8_t)(20 + intensity * 30); + uint8_t g = (uint8_t)(40 + intensity * 200); + uint8_t b = (uint8_t)(80 + intensity * 50); + gfx->fillRect(2 + i * slotW, barY, slotW - 1, 8, RGB565(r, g, b)); + } +} + +// --- Draw celebration animation --- +void drawCelebration(float t, uint8_t mascotIdx) { + float elapsed = (millis() - animStartTime) / 1000.0f; + mascots[mascotIdx].work(t); + for (int i = 0; i < 5; i++) { + float px = 3.0f + i * 2.5f + sinf(elapsed * 3 + i) * 1.5f; + float py = 4.0f - elapsed * 3.0f + i * 0.5f; + float op = fmaxf(0, 1.0f - elapsed * 0.5f); + gfx->fillRect(sx(px), sy(py), sw(1), sh(1), dim565(RGB565(255, 220, 50), op)); + } + drawCenteredText("Done!", LCD_H - 30, 2, dim565(RGB565(50, 230, 50), fmaxf(0, 1.0f - elapsed * 0.4f))); +} + +// --- Draw frustrated animation --- +void drawFrustrated(float t, uint8_t mascotIdx) { + float elapsed = (millis() - animStartTime) / 1000.0f; + float shakeX = sinf(elapsed * 30.0f) * (1.0f - elapsed * 0.5f) * 2.0f; + setViewportShiftX(shakeX); + mascots[mascotIdx].work(t); + setViewportShiftX(0.0f); + float op = fmaxf(0, 1.0f - elapsed * 0.5f); + gfx->setTextSize(3); + gfx->setTextColor(dim565(RGB565(255, 60, 60), op)); + gfx->setCursor(sx(12.0f), sy(4.0f)); + gfx->print("X"); } // --- Draw connection status --- void drawStatusBar() { uint16_t col = bleConnected ? RGB565(50, 230, 50) : RGB565(100, 100, 100); - gfx->fillRect(LCD_W / 2 - 3, 4, 6, 3, col); + gfx->fillRect(LCD_W / 2 - 3, 0, 6, 3, col); if (appMode == MODE_DEMO) { gfx->setTextColor(RGB565(60, 60, 70)); gfx->setTextSize(1); - gfx->setCursor(LCD_W / 2 - 18, 10); + gfx->setCursor(2, 0); gfx->print("DEMO"); } } +// --- Draw pair confirmation screen --- +void drawPairConfirmScreen(float t) { + drawCenteredText("Pair?", 40, 3, RGB565(235, 235, 245)); + + char hostHex[18]; + snprintf(hostHex, sizeof(hostHex), "%02X%02X%02X-%02X%02X%02X", + pendingHostId[0], pendingHostId[1], pendingHostId[2], + pendingHostId[3], pendingHostId[4], pendingHostId[5]); + drawCenteredText(hostHex, 80, 1, RGB565(120, 200, 255)); + + float pulse = (sinf(t * 2.5f) + 1.0f) * 0.5f; + uint8_t g = 140 + (uint8_t)(pulse * 60); + drawCenteredText("Press BOOT", 140, 2, RGB565(50, (uint8_t)g, 50)); + drawCenteredText("to accept", 162, 2, RGB565(50, (uint8_t)g, 50)); + + drawCenteredText("Hold BOOT", 210, 1, RGB565(180, 80, 80)); + drawCenteredText("to reject", 224, 1, RGB565(180, 80, 80)); + + unsigned long elapsed = millis() - pairRequestTime; + unsigned long remaining = 0; + if (elapsed < PAIR_CONFIRM_TIMEOUT_MS) { + remaining = (PAIR_CONFIRM_TIMEOUT_MS - elapsed) / 1000; + } + char timeBuf[8]; + snprintf(timeBuf, sizeof(timeBuf), "%lus", remaining); + drawCenteredText(timeBuf, LCD_H - 30, 2, RGB565(100, 100, 120)); +} + // --- Draw onboarding screen --- void drawOnboardScreen(float t) { drawCenteredText("Buddy", 22, 3, RGB565(235, 235, 245)); @@ -464,7 +1011,12 @@ void drawOnboardScreen(float t) { drawCenteredText("Waiting for Buddy...", y, 1, RGB565(g, g, (uint8_t)(g + 30))); } - drawCenteredText("Long press: demo", LCD_H - 18, 1, RGB565(60, 60, 80)); + if (isPaired) { + drawCenteredText("Paired", y + 14, 1, RGB565(80, 200, 80)); + drawCenteredText("Hold 3s: unpair", LCD_H - 18, 1, RGB565(60, 60, 80)); + } else { + drawCenteredText("Long press: demo", LCD_H - 18, 1, RGB565(60, 60, 80)); + } } // ============================================================ @@ -477,6 +1029,29 @@ void setup() { Serial.println("========================================"); Serial.println(" Buddy — Multi-mascot Bluetooth Pet"); Serial.println("========================================"); + + // NVS — restore persisted settings + prefs.begin("buddy", false); + buddyBrightnessPercent = prefs.getUChar("bright", BUDDY_BRIGHTNESS_DEFAULT_PERCENT); + buddyScreenOrientation = prefs.getUChar("orient", BUDDY_SCREEN_UP); + Serial.printf("[NVS] Restored brightness=%d%% orientation=%s\n", + buddyBrightnessPercent, buddyOrientationStr(buddyScreenOrientation)); + + // NVS — restore pairing state + isPaired = prefs.getBool("ps", false); + if (isPaired) { + size_t readLen = prefs.getBytes("ph", pairedHostId, HOST_ID_LENGTH); + if (readLen != HOST_ID_LENGTH) { + memset(pairedHostId, 0, HOST_ID_LENGTH); + isPaired = false; + } + } + Serial.printf("[NVS] Pairing: %s", isPaired ? "paired to " : "not paired\n"); + if (isPaired) { + Serial.printf("%02X%02X%02X%02X%02X%02X\n", + pairedHostId[0], pairedHostId[1], pairedHostId[2], + pairedHostId[3], pairedHostId[4], pairedHostId[5]); + } Serial.printf("[BOOT] Chip: %s Rev: %d Cores: %d\n", ESP.getChipModel(), ESP.getChipRevision(), ESP.getChipCores()); Serial.printf("[BOOT] CPU freq: %d MHz\n", ESP.getCpuFreqMHz()); @@ -586,7 +1161,8 @@ void loop() { // Frame rate limiter bool isSleepy = (appMode == MODE_DEMO && currentScene == SCENE_SLEEP) || (appMode == MODE_AGENT && statusToScene(bleStatusId) == SCENE_SLEEP) - || (appMode == MODE_ONBOARD); + || (appMode == MODE_ONBOARD) + || (appMode == MODE_PAIR_CONFIRM); unsigned long frameInterval = isSleepy ? FRAME_MS_SLEEP : FRAME_MS_ACTIVE; if ((now - lastFrameTime) < frameInterval) { delay(1); @@ -606,8 +1182,76 @@ void loop() { // Button handling int btn = pollButton(now); - if (appMode == MODE_AGENT) { - if (btn == 1 && bleConnected && pNotifyChar) { + + // Super-long-press detection (3s): clear pairing (factory reset) from + // ONBOARD/DEMO/AGENT modes. MODE_PAIR_CONFIRM uses normal long press. + if (btnPressed && !superLongPressFired && + (now - btnPressStart) >= SUPER_LONG_PRESS_MS && + appMode != MODE_PAIR_CONFIRM) { + superLongPressFired = true; + if (isPaired) { + Serial.println("[BTN] Super-long press -> factory reset pairing"); + clearPairedHost(); + portENTER_CRITICAL(&bleMux); + pairAuthenticated = false; + appMode = MODE_ONBOARD; + portEXIT_CRITICAL(&bleMux); + if (bleConnected) { + BLEDevice::getServer()->disconnect(0); + } + } + } + if (!btnPressed) { + superLongPressFired = false; + } + + if (appMode == MODE_PAIR_CONFIRM) { + if (btn == 1) { + uint8_t localPending[HOST_ID_LENGTH]; + portENTER_CRITICAL(&bleMux); + memcpy(localPending, pendingHostId, HOST_ID_LENGTH); + portEXIT_CRITICAL(&bleMux); + savePairedHost(localPending); + portENTER_CRITICAL(&bleMux); + pairAuthenticated = true; + hasEverConnected = true; + appMode = MODE_AGENT; + lastBleData = now; + portEXIT_CRITICAL(&bleMux); + sendPairNotify(PAIR_ACCEPTED_MARKER); + lastInteraction = now; + Serial.println("[BTN] Pairing accepted"); + } else if (btn == 2) { + sendPairNotify(PAIR_REJECTED_MARKER); + pairRejectPending = true; + pairRejectTime = now; + portENTER_CRITICAL(&bleMux); + appMode = MODE_ONBOARD; + portEXIT_CRITICAL(&bleMux); + Serial.println("[BTN] Pairing rejected"); + } + } else if (appMode == MODE_AGENT) { + Scene agentScene = statusToScene(bleStatusId); + if ((agentScene == SCENE_ALERT || agentScene == SCENE_QUESTION) && bleConnected && pNotifyChar) { + if (btn == 1) { + uint8_t payload; + if (bleStatusId == 3) { + payload = 0xF0; + Serial.println("[BTN] Approve sent (0xF0)"); + } else { + payload = bleSourceId; + Serial.println("[BTN] Focus request (question mode)"); + } + pNotifyChar->setValue(&payload, 1); + pNotifyChar->notify(); + } else if (btn == 2) { + uint8_t payload = (bleStatusId == 3) ? 0xF1 : 0xF2; + pNotifyChar->setValue(&payload, 1); + pNotifyChar->notify(); + Serial.printf("[BTN] %s sent (0x%02X)\n", + bleStatusId == 3 ? "Deny" : "Skip", payload); + } + } else if (btn == 1 && bleConnected && pNotifyChar) { uint8_t focusPayload = bleSourceId; pNotifyChar->setValue(&focusPayload, 1); pNotifyChar->notify(); @@ -643,15 +1287,35 @@ void loop() { } } + // Pair confirm timeout + if (appMode == MODE_PAIR_CONFIRM && (now - pairRequestTime) > PAIR_CONFIRM_TIMEOUT_MS) { + sendPairNotify(PAIR_REJECTED_MARKER); + pairRejectPending = true; + pairRejectTime = now; + appMode = MODE_ONBOARD; + Serial.println("[PAIR] Confirmation timeout, rejected"); + } + + // Delayed disconnect after pair rejection + if (pairRejectPending && (now - pairRejectTime) > PAIR_REJECT_DELAY_MS) { + pairRejectPending = false; + if (bleConnected) { + BLEDevice::getServer()->disconnect(0); + Serial.println("[PAIR] Disconnecting rejected client"); + } + } + // BLE timeout: agent mode -> back to previous mode if (appMode == MODE_AGENT && (now - lastBleData) > BLE_TIMEOUT_MS) { appMode = hasEverConnected ? MODE_ONBOARD : MODE_ONBOARD; Serial.printf("[BLE] Timeout (%lus no data), -> %s\n", BLE_TIMEOUT_MS / 1000, appModeStr(appMode)); } - // Dynamic backlight brightness + // Dynamic backlight brightness (with night mode) uint8_t targetBright; - if (appMode == MODE_AGENT && bleConnected) { + if (appMode == MODE_PAIR_CONFIRM) { + targetBright = activeBrightness(); + } else if (appMode == MODE_AGENT && bleConnected) { lastInteraction = now; Scene agentScene = statusToScene(bleStatusId); targetBright = (agentScene == SCENE_SLEEP) ? sleepBrightness() : activeBrightness(); @@ -662,6 +1326,14 @@ void loop() { } else { targetBright = (currentScene == SCENE_SLEEP) ? sleepBrightness() : activeBrightness(); } + // Night mode: reduce brightness during late hours + if (bleCurrentHour != 255) { + if (bleCurrentHour >= 22 || bleCurrentHour < 6) { + targetBright = min(targetBright, idleBrightness()); + } else if (bleCurrentHour >= 18) { + targetBright = min(targetBright, sleepBrightness()); + } + } if (currentBrightness != targetBright) { uint8_t prevBright = currentBrightness; if (currentBrightness < targetBright) currentBrightness += min((uint8_t)3, (uint8_t)(targetBright - currentBrightness)); @@ -675,7 +1347,9 @@ void loop() { // ---- Render ---- canvas.fillScreen(0x0000); - if (appMode == MODE_ONBOARD) { + if (appMode == MODE_PAIR_CONFIRM) { + drawPairConfirmScreen(t); + } else if (appMode == MODE_ONBOARD) { drawOnboardScreen(t); } else { uint8_t drawIdx; @@ -702,23 +1376,130 @@ void loop() { drawScene = statusToScene(bleStatusId); } + // Header area (y=0..16) drawStatusBar(); + drawMascotName(drawIdx); + // Mascot animation area (y=16..220) Mascot& m = mascots[drawIdx]; - switch (drawScene) { - case SCENE_SLEEP: m.sleep(t); break; - case SCENE_WORK: m.work(t); break; - case SCENE_ALERT: m.alert(t); break; - default: break; - } - drawMascotName(drawIdx); + // Check transient animations + bool playingTransient = false; + if (pendingAnim != ANIM_NONE && (millis() - animStartTime) < ANIM_DURATION_MS) { + playingTransient = true; + if (pendingAnim == ANIM_CELEBRATE) { + drawCelebration(t, drawIdx); + } else if (pendingAnim == ANIM_FRUSTRATED) { + drawFrustrated(t, drawIdx); + } + } else { + if (pendingAnim != ANIM_NONE) pendingAnim = ANIM_NONE; + + // Bored detection for idle mascots + if (drawScene == SCENE_SLEEP) { + unsigned long idleDuration = now - lastBleData; + globalBored = (idleDuration > 300000UL); // 5 minutes + if (globalBored) { + float boredCycle = fmodf(t, 8.0f); + if (boredCycle < 1.0f) globalBoredEyeOffsetX = -1.0f; + else if (boredCycle > 3.0f && boredCycle < 4.0f) globalBoredEyeOffsetX = 1.0f; + else globalBoredEyeOffsetX = 0.0f; + } else { + globalBoredEyeOffsetX = 0.0f; + } + } else { + globalBored = false; + globalBoredEyeOffsetX = 0.0f; + } + + // Compute activity-based time scale for work animations + if (drawScene == SCENE_WORK) { + globalWorkTimeScale = 1.0f; + if (bleWriteInterval < 2000) globalWorkTimeScale = 1.5f; + if (bleWriteInterval < 500) globalWorkTimeScale = 2.0f; + } + + switch (drawScene) { + case SCENE_SLEEP: m.sleep(t); break; + case SCENE_WORK: m.work(t * globalWorkTimeScale); break; + case SCENE_ALERT: m.alert(t); break; + case SCENE_QUESTION: m.question(t); break; + default: break; + } + + // Subagent dots during work scene + if (drawScene == SCENE_WORK && bleSubagentCount > 0) { + drawSubagentDots(bleSubagentCount, 0); + } + } - if (appMode == MODE_AGENT) drawToolLabel(); + // Info area (AGENT mode only) + if (appMode == MODE_AGENT && !playingTransient) { + if (drawScene == SCENE_SLEEP) { + drawWorkspaceLabel(224); + drawStatsPanel(); + drawToolTimeline(); + drawHeatmapBar(); + } else if (drawScene == SCENE_ALERT || drawScene == SCENE_QUESTION) { + drawWorkspaceLabel(274); + drawToolLabel(286); + drawAlertActionHints(bleStatusId); + } else { + drawWorkspaceLabel(292); + drawToolLabel(304); + } + } } tft.drawRGBBitmap(0, 0, canvas.getBuffer(), LCD_W, LCD_H); + // NVS debounce write + if (nvsDirty && (now - lastNvsSave) > NVS_DEBOUNCE_MS) { + prefs.putUChar("bright", buddyBrightnessPercent); + prefs.putUChar("orient", buddyScreenOrientation); + nvsDirty = false; + lastNvsSave = now; + Serial.printf("[NVS] Saved brightness=%d%% orientation=%s\n", + buddyBrightnessPercent, buddyOrientationStr(buddyScreenOrientation)); + } + +#ifdef BUDDY_OTA_ENABLED + // OTA initialization (deferred from BLE callback to avoid blocking) + if (otaPending && !otaEnabled) { + otaPending = false; + Serial.println("[OTA] Starting WiFi + OTA from main loop..."); + WiFi.begin(otaSsid, otaPassword); + ArduinoOTA.setHostname(bleDeviceName); + ArduinoOTA.onStart([]() { + canvas.fillScreen(0x0000); + drawCenteredText("OTA Update", 100, 2, RGB565(255, 200, 50)); + tft.drawRGBBitmap(0, 0, canvas.getBuffer(), LCD_W, LCD_H); + }); + ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { + int pct = progress * 100 / total; + char buf[16]; + snprintf(buf, sizeof(buf), "%d%%", pct); + canvas.fillScreen(0x0000); + drawCenteredText("OTA Update", 100, 2, RGB565(255, 200, 50)); + drawCenteredText(buf, 140, 2, RGB565(200, 200, 200)); + int barW = LCD_W * pct / 100; + canvas.fillRect(0, 160, barW, 8, RGB565(50, 200, 50)); + canvas.fillRect(barW, 160, LCD_W - barW, 8, RGB565(40, 40, 40)); + tft.drawRGBBitmap(0, 0, canvas.getBuffer(), LCD_W, LCD_H); + }); + ArduinoOTA.onEnd([]() { + canvas.fillScreen(0x0000); + drawCenteredText("Rebooting...", 120, 2, RGB565(50, 230, 50)); + tft.drawRGBBitmap(0, 0, canvas.getBuffer(), LCD_W, LCD_H); + }); + ArduinoOTA.begin(); + otaEnabled = true; + } + + // OTA handle + if (otaEnabled) ArduinoOTA.handle(); +#endif + // Periodic status log if ((now - lastLogTime) >= LOG_INTERVAL_MS) { lastLogTime = now; @@ -727,10 +1508,11 @@ void loop() { Serial.printf("[STAT] up=%lus | fps=%.1f | heap=%d | bright=%d/255 (%d%%)\n", upSec, currentFps, ESP.getFreeHeap(), currentBrightness, buddyBrightnessPercent); - Serial.printf("[STAT] mode=%s | ble=%s | ever_connected=%s\n", + Serial.printf("[STAT] mode=%s | ble=%s | paired=%s | auth=%s\n", appModeStr(appMode), bleConnected ? "CONNECTED" : "disconnected", - hasEverConnected ? "yes" : "no"); + isPaired ? "yes" : "no", + pairAuthenticated ? "yes" : "no"); if (appMode == MODE_AGENT) { char toolBuf[18]; diff --git a/hardware/mascot_antigrav.h b/hardware/mascot_antigrav.h index 16704f5b..fc675f97 100644 --- a/hardware/mascot_antigrav.h +++ b/hardware/mascot_antigrav.h @@ -76,3 +76,9 @@ void antigravAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, 1.0f, jumpY, jumpY, AG_ALERT); } + +void antigravQuestion(float t) { + _questionMode = true; + antigravAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_buddy.h b/hardware/mascot_buddy.h index 1d5318bb..1ebf9c6d 100644 --- a/hardware/mascot_buddy.h +++ b/hardware/mascot_buddy.h @@ -83,3 +83,9 @@ void buddyAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, BUD_ALERT); } + +void buddyQuestion(float t) { + _questionMode = true; + buddyAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_clawd.h b/hardware/mascot_clawd.h index 2bec970c..309a59d9 100644 --- a/hardware/mascot_clawd.h +++ b/hardware/mascot_clawd.h @@ -38,8 +38,10 @@ void clawdSleep(float t) { gfx->fillRect(sx(-1), sy(13), sw(2), sh(2), CLAWD_BODY); gfx->fillRect(sx(14), sy(13), sw(2), sh(2), CLAWD_BODY); float eyeY = 12.2f - puff * 2.5f; - gfx->fillRect(sx(3), sy(eyeY), sw(2.5f), sh(1.0f), CLAWD_EYE); - gfx->fillRect(sx(9.5f), sy(eyeY), sw(2.5f), sh(1.0f), CLAWD_EYE); + float eyeOff = globalBoredEyeOffsetX * 0.5f; + gfx->fillRect(sx(3 + eyeOff), sy(eyeY), sw(2.5f), sh(1.0f), CLAWD_EYE); + gfx->fillRect(sx(9.5f + eyeOff), sy(eyeY), sw(2.5f), sh(1.0f), CLAWD_EYE); + drawBoredYawn(t, 6.0f, 13.5f); drawZParticles(t); } @@ -126,3 +128,9 @@ void clawdAlert(float t) { fillRotatedRect(13, 9, 2, 2, 13, 10, aR, dy, CLAWD_BODY); drawBang(bangOp, bangSc, jumpY, dy, CLAWD_ALERT, 4.5f); } + +void clawdQuestion(float t) { + _questionMode = true; + clawdAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_common.h b/hardware/mascot_common.h index 3c08adac..604f2c16 100644 --- a/hardware/mascot_common.h +++ b/hardware/mascot_common.h @@ -94,6 +94,21 @@ static const float kfBangScCommon[] = { 0,0.3f, 0.03f,1.3f, 0.10f,1.0f, 0.55f,1.0f, 0.62f,0.6f, 1.0f,0.6f }; +// Global bored state (set by main loop, read by mascot sleep functions) +extern bool globalBored; +extern float globalBoredEyeOffsetX; + +// Draw yawn mouth for bored state +inline void drawBoredYawn(float t, float mouthX, float mouthY, float dy = 0) { + if (!globalBored) return; + float boredCycle = fmodf(t, 8.0f); + if (boredCycle > 6.0f && boredCycle < 6.5f) { + float openAmount = sinf((boredCycle - 6.0f) * PI * 2.0f); + float mh = 1.0f + openAmount * 0.8f; + gfx->fillRect(sx(mouthX), sy(mouthY, dy), sw(2.0f), sh(mh), RGB565(80, 60, 60)); + } +} + // Draw floating "z" particles (shared sleep element) inline void drawZParticles(float t, float baseX = 11.8f, float baseY = 7.7f, uint16_t baseColor = 0xFFFF) { @@ -135,11 +150,31 @@ inline void drawKeyboard(float t, uint16_t baseCol, uint16_t keyCol, uint16_t hi sw(1.8f), sh(0.7f), hiCol); } +// Global flag: when true, drawBang renders a "?" instead of "!" +static bool _questionMode = false; +static const uint16_t QUESTION_COLOR = RGB565(80, 160, 255); + +// Draw question mark (shared question element — blue "?" instead of red "!") +inline void drawQuestionMark(float bangOp, float bangSc, float jumpY, + uint16_t questionCol, float baseY = 4.0f) { + if (bangOp <= 0.05f) return; + float bx = 12.5f; + float by = baseY + jumpY * 0.15f; + gfx->setTextSize(3); + gfx->setTextColor(dim565(questionCol, bangOp)); + gfx->setCursor(sx(bx), sy(by)); + gfx->print("?"); +} + // Draw exclamation bang (shared alert element) inline void drawBang(float bangOp, float bangSc, float jumpY, float dy, uint16_t alertCol, float baseY = 4.0f) { (void)dy; if (bangOp <= 0.05f) return; + if (_questionMode) { + drawQuestionMark(bangOp, bangSc, jumpY, QUESTION_COLOR, baseY); + return; + } float bx = 13.0f; float by = baseY + jumpY * 0.15f; float bw = 2.0f * bangSc; @@ -149,6 +184,68 @@ inline void drawBang(float bangOp, float bangSc, float jumpY, float dy, gfx->fillRect(sx(bx), sy(by + 4.0f * bangSc), sw(bw), sh(bh2), alertCol); } +// Tool icon classification +enum ToolIcon { ICON_NONE, ICON_TERMINAL, ICON_FILE, ICON_WEB, ICON_AGENT, ICON_SEARCH }; + +inline ToolIcon classifyTool(const char* tool) { + if (strcmp(tool, "Bash") == 0 || strcmp(tool, "Shell") == 0) return ICON_TERMINAL; + if (strcmp(tool, "Read") == 0 || strcmp(tool, "Write") == 0 || + strcmp(tool, "Edit") == 0 || strcmp(tool, "Glob") == 0) return ICON_FILE; + if (strcmp(tool, "WebSearch") == 0 || strcmp(tool, "WebFetch") == 0) return ICON_WEB; + if (strcmp(tool, "Task") == 0 || strcmp(tool, "Agent") == 0) return ICON_AGENT; + if (strcmp(tool, "Grep") == 0) return ICON_SEARCH; + return ICON_NONE; +} + +// 8x8 pixel icon bitmaps (stored in PROGMEM) +static const uint8_t ICON_TERMINAL_BMP[] PROGMEM = { + 0xFF, 0x81, 0xA1, 0x91, 0x89, 0x85, 0x81, 0xFF +}; +static const uint8_t ICON_FILE_BMP[] PROGMEM = { + 0x3C, 0x24, 0x24, 0x24, 0x24, 0x24, 0x24, 0x3C +}; +static const uint8_t ICON_WEB_BMP[] PROGMEM = { + 0x3C, 0x42, 0xFF, 0x42, 0x42, 0xFF, 0x42, 0x3C +}; +static const uint8_t ICON_AGENT_BMP[] PROGMEM = { + 0x3C, 0x42, 0xA5, 0x81, 0xA5, 0x99, 0x42, 0x3C +}; +static const uint8_t ICON_SEARCH_BMP[] PROGMEM = { + 0x38, 0x44, 0x44, 0x44, 0x38, 0x0E, 0x07, 0x03 +}; + +inline void drawToolIcon(ToolIcon icon, int px, int py, uint16_t color) { + if (icon == ICON_NONE) return; + const uint8_t* bmp = nullptr; + switch (icon) { + case ICON_TERMINAL: bmp = ICON_TERMINAL_BMP; break; + case ICON_FILE: bmp = ICON_FILE_BMP; break; + case ICON_WEB: bmp = ICON_WEB_BMP; break; + case ICON_AGENT: bmp = ICON_AGENT_BMP; break; + case ICON_SEARCH: bmp = ICON_SEARCH_BMP; break; + default: return; + } + for (int row = 0; row < 8; row++) { + uint8_t bits = pgm_read_byte(&bmp[row]); + for (int col = 0; col < 8; col++) { + if (bits & (0x80 >> col)) { + gfx->drawPixel(px + col, py + row, color); + } + } + } +} + +// Subagent dots above mascot head +inline void drawSubagentDots(uint8_t count, float dy) { + if (count == 0) return; + uint8_t n = min(count, (uint8_t)5); + float startX = 7.5f - n * 0.8f; + for (uint8_t i = 0; i < n; i++) { + gfx->fillCircle(sx(startX + i * 1.6f), sy(5.0f, dy), sw(0.4f), + RGB565(100, 220, 255)); + } +} + // Shadow helper inline void drawShadow(float width, float jumpY = 0, float y = 15.0f, float xBase = 3.0f, float baseWidth = 9.0f) { diff --git a/hardware/mascot_copilot.h b/hardware/mascot_copilot.h index 59abd963..602d21a0 100644 --- a/hardware/mascot_copilot.h +++ b/hardware/mascot_copilot.h @@ -91,3 +91,9 @@ void copilotAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, COP_ALERT); } + +void copilotQuestion(float t) { + _questionMode = true; + copilotAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_cursor.h b/hardware/mascot_cursor.h index 6f904d44..278722ed 100644 --- a/hardware/mascot_cursor.h +++ b/hardware/mascot_cursor.h @@ -90,3 +90,9 @@ void cursorAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, CUR_ALERT); } + +void cursorQuestion(float t) { + _questionMode = true; + cursorAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_dex.h b/hardware/mascot_dex.h index 5f93d184..88590f26 100644 --- a/hardware/mascot_dex.h +++ b/hardware/mascot_dex.h @@ -89,3 +89,9 @@ void dexAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, DEX_ALERT); } + +void dexQuestion(float t) { + _questionMode = true; + dexAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_droid.h b/hardware/mascot_droid.h index baa42ee3..e47eed64 100644 --- a/hardware/mascot_droid.h +++ b/hardware/mascot_droid.h @@ -94,3 +94,9 @@ void droidAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, DRD_ALERT, 3.0f); } + +void droidQuestion(float t) { + _questionMode = true; + droidAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_gemini.h b/hardware/mascot_gemini.h index 32172b39..f3f1b853 100644 --- a/hardware/mascot_gemini.h +++ b/hardware/mascot_gemini.h @@ -82,3 +82,9 @@ void geminiAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, GEM_ALERT); } + +void geminiQuestion(float t) { + _questionMode = true; + geminiAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_hermes.h b/hardware/mascot_hermes.h index 6b3029b4..e5f6ddf3 100644 --- a/hardware/mascot_hermes.h +++ b/hardware/mascot_hermes.h @@ -73,3 +73,9 @@ void hermesAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, 1.0f, jumpY, jumpY, HRM_ALERT); } + +void hermesQuestion(float t) { + _questionMode = true; + hermesAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_kimi.h b/hardware/mascot_kimi.h index 9d9e28fb..635342d8 100644 --- a/hardware/mascot_kimi.h +++ b/hardware/mascot_kimi.h @@ -72,3 +72,9 @@ void kimiAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, KIM_ALERT); } + +void kimiQuestion(float t) { + _questionMode = true; + kimiAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_opencode.h b/hardware/mascot_opencode.h index e0356189..54e59e1b 100644 --- a/hardware/mascot_opencode.h +++ b/hardware/mascot_opencode.h @@ -86,3 +86,9 @@ void opencodeAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, OPC_ALERT); } + +void opencodeQuestion(float t) { + _questionMode = true; + opencodeAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_qoder.h b/hardware/mascot_qoder.h index ff2ad1ce..f2820649 100644 --- a/hardware/mascot_qoder.h +++ b/hardware/mascot_qoder.h @@ -82,3 +82,9 @@ void qoderAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, QOD_ALERT); } + +void qoderQuestion(float t) { + _questionMode = true; + qoderAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_qwen.h b/hardware/mascot_qwen.h index d8afc9e5..652ac972 100644 --- a/hardware/mascot_qwen.h +++ b/hardware/mascot_qwen.h @@ -90,3 +90,9 @@ void qwenAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, QWN_ALERT); } + +void qwenQuestion(float t) { + _questionMode = true; + qwenAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_stepfun.h b/hardware/mascot_stepfun.h index 00462ba1..5c4aad43 100644 --- a/hardware/mascot_stepfun.h +++ b/hardware/mascot_stepfun.h @@ -70,3 +70,9 @@ void stepfunAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, 1.0f, jumpY, jumpY, STP_ALERT); } + +void stepfunQuestion(float t) { + _questionMode = true; + stepfunAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_trae.h b/hardware/mascot_trae.h index 819f8e15..57689ffa 100644 --- a/hardware/mascot_trae.h +++ b/hardware/mascot_trae.h @@ -76,3 +76,9 @@ void traeAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, bangSc, jumpY, jumpY, TRAE_ALERT); } + +void traeQuestion(float t) { + _questionMode = true; + traeAlert(t); + _questionMode = false; +} diff --git a/hardware/mascot_workbuddy.h b/hardware/mascot_workbuddy.h index da4884df..27708780 100644 --- a/hardware/mascot_workbuddy.h +++ b/hardware/mascot_workbuddy.h @@ -74,3 +74,9 @@ void workbuddyAlert(float t) { setViewportShiftX(0.0f); drawBang(bangOp, 1.0f, jumpY, jumpY, WB_ALERT); } + +void workbuddyQuestion(float t) { + _questionMode = true; + workbuddyAlert(t); + _questionMode = false; +} From 18047a07907e12f27f4d00fdffe6f045cf3406df Mon Sep 17 00:00:00 2001 From: lakphy Date: Fri, 15 May 2026 01:57:43 +0800 Subject: [PATCH 4/7] feat: enhance Buddy pairing process and add legacy support - Updated ESP32BridgeManager to handle legacy pairing fallback and timeout scenarios. - Improved error messages and status handling for pairing states. - Added new payload for clearing tool history. - Enhanced localization strings for better user guidance. - Updated README with clearer pairing instructions and hints for legacy firmware mode. - Added tests for new functionality in AppState and ESP32Protocol. --- Sources/CodeIsland/ESP32BridgeManager.swift | 83 ++++++++++++++++--- Sources/CodeIsland/ESP32StatePublisher.swift | 20 ++++- Sources/CodeIsland/L10n.swift | 25 +++--- Sources/CodeIsland/SettingsView.swift | 12 ++- Sources/CodeIslandCore/ESP32Protocol.swift | 13 ++- .../ESP32ProtocolTests.swift | 9 +- .../AppStatePrimarySourceTests.swift | 17 ++++ hardware/README.md | 10 ++- hardware/hardware.ino | 6 ++ 9 files changed, 165 insertions(+), 30 deletions(-) diff --git a/Sources/CodeIsland/ESP32BridgeManager.swift b/Sources/CodeIsland/ESP32BridgeManager.swift index 5c7c68fe..f632fd45 100644 --- a/Sources/CodeIsland/ESP32BridgeManager.swift +++ b/Sources/CodeIsland/ESP32BridgeManager.swift @@ -2,6 +2,7 @@ import CoreBluetooth import Foundation import Observation import os +import Security import CodeIslandCore /// Connection lifecycle state for the Buddy Bluetooth bridge. @@ -14,7 +15,7 @@ enum ESP32BridgeStatus: Equatable { case connecting // found the selected one, connecting / discovering characteristics case pairing // BLE connected, pair request sent, waiting for Buddy response case pairWaitingConfirm // Buddy shows confirmation screen, waiting for user button press - case pairRejected // Buddy is paired with another host + case pairRejected // Buddy rejected pairing or pairing timed out case connected // ready to write + receiving notifications case reconnecting(Int) // seconds until next attempt to find the selected Buddy @@ -69,6 +70,7 @@ final class ESP32BridgeManager: NSObject { private(set) var discovered: [DiscoveredBuddy] = [] private(set) var selectedBuddyIdentifier: UUID? private(set) var selectedBuddyName: String? + private(set) var usesLegacyPairingFallback = false // Backoff table (seconds) mirrors Buddy's 1→2→4→8→…30 exponential. private static let reconnectBackoff: [Int] = [1, 2, 4, 8, 16, 30] @@ -92,8 +94,10 @@ final class ESP32BridgeManager: NSObject { /// Set to `true` inside `forgetSelection()` so the disconnect callback /// knows not to schedule a reconnect. private var forgetting = false - /// Fires after `pairConfirmTimeoutSeconds` if no pair response arrives. + /// Fires after `pairConfirmTimeoutSeconds` while Buddy is waiting for BOOT confirmation. private var pairTimeoutTimer: Timer? + /// Fires when no pair response arrives at all, which indicates pre-pairing firmware. + private var pairLegacyFallbackTimer: Timer? /// Callback fired when Buddy notifies a button press with a /// mascot `sourceId` byte. Nonisolated to allow CoreBluetooth delegate @@ -125,6 +129,7 @@ final class ESP32BridgeManager: NSObject { func start() { guard status == .off else { return } lastError = nil + usesLegacyPairingFallback = false ensureCentral() attemptReconnectToSelected() } @@ -132,7 +137,7 @@ final class ESP32BridgeManager: NSObject { /// Disable the bridge, tear down peripheral + scan + discovery. func stop() { cancelReconnectTimer() - cancelPairTimeout() + cancelPairingTimers() stopDiscoveryInternal(updateStatus: false) if let central, central.isScanning { central.stopScan() } if let peripheral, let central { @@ -143,6 +148,7 @@ final class ESP32BridgeManager: NSObject { notifyChar = nil notifySubscriptionReady = false connectedPeripheralName = nil + usesLegacyPairingFallback = false status = .off } @@ -159,7 +165,10 @@ final class ESP32BridgeManager: NSObject { central.scanForPeripherals(withServices: [serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) Self.log.info("Discovery scan started") - if status != .connected, status != .connecting { + if status != .connected, + status != .connecting, + status != .pairing, + status != .pairWaitingConfirm { status = .scanning } } @@ -184,6 +193,8 @@ final class ESP32BridgeManager: NSObject { // Tear down any current connection and try the new selection. cancelReconnectTimer() + cancelPairingTimers() + usesLegacyPairingFallback = false if let peripheral, let central, peripheral.identifier != buddyId { central.cancelPeripheralConnection(peripheral) } @@ -201,7 +212,7 @@ final class ESP32BridgeManager: NSObject { /// its NVS, then disconnect and clear all persisted state. func forgetSelection() { cancelReconnectTimer() - cancelPairTimeout() + cancelPairingTimers() forgetting = true // Tell Buddy to clear its paired-host record before we drop the link. @@ -225,6 +236,7 @@ final class ESP32BridgeManager: NSObject { notifyChar = nil notifySubscriptionReady = false connectedPeripheralName = nil + usesLegacyPairingFallback = false selectedBuddyIdentifier = nil selectedBuddyName = nil defaults.removeObject(forKey: SettingsKey.selectedBuddyIdentifier) @@ -282,6 +294,11 @@ final class ESP32BridgeManager: NSObject { send(entry.encode()) } + /// Clear Buddy's tool history timeline. No-op when not connected. + func sendToolHistoryClear() { + send(BuddyToolHistoryClearPayload().encode()) + } + private func send(_ data: Data) { guard let peripheral, let writeChar, status == .connected else { return } peripheral.writeValue(data, for: writeChar, type: .withoutResponse) @@ -338,7 +355,19 @@ final class ESP32BridgeManager: NSObject { let payload = BuddyPairRequestPayload(hostId: hostIdentifier) peripheral.writeValue(payload.encode(), for: writeChar, type: .withoutResponse) Self.log.info("Pair request sent") - schedulePairTimeout() + scheduleLegacyPairFallback() + } + + private func scheduleLegacyPairFallback() { + pairLegacyFallbackTimer?.invalidate() + pairLegacyFallbackTimer = Timer.scheduledTimer( + withTimeInterval: ESP32Protocol.pairLegacyFallbackSeconds, + repeats: false + ) { [weak self] _ in + Task { @MainActor in + self?.handleLegacyPairFallback() + } + } } private func schedulePairTimeout() { @@ -358,16 +387,37 @@ final class ESP32BridgeManager: NSObject { pairTimeoutTimer = nil } + private func cancelLegacyPairFallback() { + pairLegacyFallbackTimer?.invalidate() + pairLegacyFallbackTimer = nil + } + + private func cancelPairingTimers() { + cancelPairTimeout() + cancelLegacyPairFallback() + } + private func handlePairTimeout() { - guard status == .pairing || status == .pairWaitingConfirm else { return } - Self.log.error("Pair handshake timed out after \(ESP32Protocol.pairConfirmTimeoutSeconds)s") - lastError = "Pairing timed out. Buddy did not respond." + guard status == .pairWaitingConfirm else { return } + Self.log.error("Pair confirmation timed out after \(ESP32Protocol.pairConfirmTimeoutSeconds)s") + pairTimeoutTimer = nil + lastError = "Pairing was not completed. Press BOOT on Buddy to approve, or hold BOOT for 3s to reset pairing." status = .pairRejected if let peripheral, let central { central.cancelPeripheralConnection(peripheral) } } + private func handleLegacyPairFallback() { + guard status == .pairing else { return } + Self.log.info("No pair response received; continuing in legacy Buddy firmware compatibility mode") + pairLegacyFallbackTimer = nil + usesLegacyPairingFallback = true + lastError = nil + status = .connected + onConnected?() + } + private func loadSelectionFromDefaults() { if let raw = defaults.string(forKey: SettingsKey.selectedBuddyIdentifier), !raw.isEmpty, @@ -578,6 +628,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { Task { @MainActor in Self.log.info("Connected, discovering services") + self.usesLegacyPairingFallback = false // If discovery is still running we no longer need to scan once // we have the selected device hooked up. if !self.discoveryActive, central.isScanning { @@ -592,6 +643,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { error: Error?) { Task { @MainActor in Self.log.error("Failed to connect: \(error?.localizedDescription ?? "unknown")") + self.cancelPairingTimers() self.lastError = error?.localizedDescription self.peripheral = nil self.writeChar = nil @@ -607,6 +659,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { error: Error?) { Task { @MainActor in Self.log.info("Disconnected: \(error?.localizedDescription ?? "peer closed")") + self.cancelPairingTimers() self.peripheral = nil self.writeChar = nil self.notifyChar = nil @@ -765,16 +818,20 @@ extension ESP32BridgeManager: CBPeripheralDelegate { @MainActor private func handlePairResponse(_ response: BuddyPairResponse) { - cancelPairTimeout() + cancelLegacyPairFallback() switch response { case .accepted: Self.log.info("Pair accepted by Buddy") + cancelPairTimeout() lastError = nil + usesLegacyPairingFallback = false status = .connected onConnected?() case .rejected: - Self.log.error("Pair rejected — Buddy is paired with another host") - lastError = "Buddy is paired with another device. Hold BOOT for 3s on Buddy to reset pairing." + Self.log.error("Pair rejected or not completed by Buddy") + cancelPairTimeout() + lastError = "Pairing was not completed. If Buddy is paired with another Mac, hold BOOT for 3s on Buddy to reset pairing." + usesLegacyPairingFallback = false status = .pairRejected if let peripheral, let central { central.cancelPeripheralConnection(peripheral) @@ -782,7 +839,9 @@ extension ESP32BridgeManager: CBPeripheralDelegate { case .pending: Self.log.info("Pair pending — waiting for user confirmation on Buddy") lastError = nil + usesLegacyPairingFallback = false status = .pairWaitingConfirm + schedulePairTimeout() } } } diff --git a/Sources/CodeIsland/ESP32StatePublisher.swift b/Sources/CodeIsland/ESP32StatePublisher.swift index fa29b51a..31859ca1 100644 --- a/Sources/CodeIsland/ESP32StatePublisher.swift +++ b/Sources/CodeIsland/ESP32StatePublisher.swift @@ -85,13 +85,25 @@ final class ESP32StatePublisher { let session = appState.esp32DisplaySession() let frame = appState.esp32DisplayFrame(session: session) bridge.send(frame) + + if bridge.usesLegacyPairingFallback { + lastSentStatus = frame.status + Self.log.debug("push(\(reason), legacy): mascot=\(frame.mascot.sourceName) status=\(frame.status.rawValue) tool=\(frame.toolName ?? "")") + return + } + bridge.sendWorkspace(appState.esp32WorkspacePayload(session: session)) appState.esp32MessagePreviewPayloads(session: session).forEach { bridge.sendMessagePreview($0) } bridge.sendModel(appState.esp32ModelPayload(session: session)) bridge.sendStats(appState.esp32StatsPayload(session: session)) bridge.sendSubagent(appState.esp32SubagentPayload(session: session)) bridge.sendTimeHint(BuddyTimeHintPayload(hour: Calendar.current.component(.hour, from: Date()))) - appState.esp32ToolHistoryPayloads(session: session).forEach { bridge.sendToolHistory($0) } + let toolHistory = appState.esp32ToolHistoryPayloads(session: session) + if toolHistory.isEmpty { + bridge.sendToolHistoryClear() + } else { + toolHistory.forEach { bridge.sendToolHistory($0) } + } // Detect status transitions for event animations let currentStatus = frame.status @@ -266,7 +278,7 @@ extension AppState { } func esp32StatsPayload(session: SessionSnapshot? = nil) -> BuddyStatsPayload { - let toolCount = session?.totalToolCallCount ?? 0 + let toolCount = esp32TotalToolCallCount() let durationMin: Int if let start = session?.startTime { durationMin = Int(Date().timeIntervalSince(start) / 60.0) @@ -281,6 +293,10 @@ extension AppState { ) } + func esp32TotalToolCallCount() -> Int { + sessions.values.reduce(0) { $0 + $1.totalToolCallCount } + } + func esp32SubagentPayload(session: SessionSnapshot? = nil) -> BuddySubagentPayload { BuddySubagentPayload(count: session?.activeSubagentCount ?? 0) } diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index a4ef10f9..ec2f4529 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -254,9 +254,10 @@ final class L10n: ObservableObject { "buddy_status_connected": "Connected", "buddy_status_pairing": "Pairing…", "buddy_status_pair_waiting_confirm": "Press BOOT on Buddy", - "buddy_status_pair_rejected": "Pair rejected", + "buddy_status_pair_rejected": "Pairing not completed", "buddy_pair_confirm_hint": "Press the BOOT button on Buddy to confirm pairing", - "buddy_pair_rejected_hint": "This Buddy is paired with another device. Hold BOOT for 3s on Buddy to reset.", + "buddy_pair_rejected_hint": "Pairing was rejected or timed out. If this Buddy is paired with another Mac, hold BOOT for 3s to reset.", + "buddy_legacy_firmware_hint": "Connected in legacy firmware mode. Flash the latest Buddy firmware to enable pairing and richer status panels.", "buddy_status_reconnecting": "Reconnecting in %d s…", "buddy_status_no_selection": "No Buddy selected", "buddy_status_searching_selected": "Waiting for selected Buddy…", @@ -563,9 +564,10 @@ final class L10n: ObservableObject { "buddy_status_connected": "已连接", "buddy_status_pairing": "配对中…", "buddy_status_pair_waiting_confirm": "请按 Buddy 上的 BOOT 按钮", - "buddy_status_pair_rejected": "配对被拒绝", + "buddy_status_pair_rejected": "配对未完成", "buddy_pair_confirm_hint": "请按 Buddy 上的 BOOT 按钮确认配对", - "buddy_pair_rejected_hint": "此 Buddy 已与其他设备配对。在 Buddy 上长按 BOOT 3秒可重置配对。", + "buddy_pair_rejected_hint": "配对被拒绝或已超时。如果此 Buddy 已与另一台 Mac 配对,请长按 BOOT 3 秒重置。", + "buddy_legacy_firmware_hint": "已使用旧固件兼容模式连接。请烧录最新 Buddy 固件以启用配对和更丰富的状态面板。", "buddy_status_reconnecting": "%d 秒后重新连接…", "buddy_status_no_selection": "未选择 Buddy", "buddy_status_searching_selected": "等待已选 Buddy 出现…", @@ -872,9 +874,10 @@ final class L10n: ObservableObject { "buddy_status_connected": "接続済み", "buddy_status_pairing": "ペアリング中…", "buddy_status_pair_waiting_confirm": "Buddy の BOOT ボタンを押してください", - "buddy_status_pair_rejected": "ペアリング拒否", + "buddy_status_pair_rejected": "ペアリング未完了", "buddy_pair_confirm_hint": "Buddy の BOOT ボタンを押してペアリングを確認してください", - "buddy_pair_rejected_hint": "この Buddy は別のデバイスとペアリング済みです。BOOT を3秒長押しでリセットできます。", + "buddy_pair_rejected_hint": "ペアリングが拒否されたかタイムアウトしました。別の Mac とペアリング済みの場合は、BOOT を3秒長押ししてリセットしてください。", + "buddy_legacy_firmware_hint": "旧ファームウェア互換モードで接続しました。ペアリングと詳細な状態表示を有効にするには、最新の Buddy ファームウェアを書き込んでください。", "buddy_status_reconnecting": "%d 秒後に再接続…", "buddy_status_no_selection": "Buddy が未選択", "buddy_status_searching_selected": "選択した Buddy を探しています…", @@ -1181,9 +1184,10 @@ final class L10n: ObservableObject { "buddy_status_connected": "연결됨", "buddy_status_pairing": "페어링 중…", "buddy_status_pair_waiting_confirm": "Buddy의 BOOT 버튼을 누르세요", - "buddy_status_pair_rejected": "페어링 거부됨", + "buddy_status_pair_rejected": "페어링 미완료", "buddy_pair_confirm_hint": "Buddy의 BOOT 버튼을 눌러 페어링을 확인하세요", - "buddy_pair_rejected_hint": "이 Buddy는 다른 기기와 페어링되어 있습니다. BOOT를 3초간 길게 누르면 초기화됩니다.", + "buddy_pair_rejected_hint": "페어링이 거부되었거나 시간이 초과되었습니다. 다른 Mac과 페어링된 Buddy라면 BOOT를 3초간 길게 눌러 초기화하세요.", + "buddy_legacy_firmware_hint": "기존 펌웨어 호환 모드로 연결되었습니다. 페어링과 더 풍부한 상태 패널을 사용하려면 최신 Buddy 펌웨어를 플래시하세요.", "buddy_status_reconnecting": "%d초 후 다시 연결…", "buddy_status_no_selection": "선택된 Buddy 없음", "buddy_status_searching_selected": "선택한 Buddy를 기다리는 중…", @@ -1490,9 +1494,10 @@ final class L10n: ObservableObject { "buddy_status_connected": "Bağlı", "buddy_status_pairing": "Eşleştiriliyor…", "buddy_status_pair_waiting_confirm": "Buddy'de BOOT'a basın", - "buddy_status_pair_rejected": "Eşleştirme reddedildi", + "buddy_status_pair_rejected": "Eşleştirme tamamlanmadı", "buddy_pair_confirm_hint": "Eşleştirmeyi onaylamak için Buddy'deki BOOT düğmesine basın", - "buddy_pair_rejected_hint": "Bu Buddy başka bir cihazla eşleştirilmiş. Sıfırlamak için BOOT'u 3sn basılı tutun.", + "buddy_pair_rejected_hint": "Eşleştirme reddedildi veya zaman aşımına uğradı. Buddy başka bir Mac ile eşleşmişse sıfırlamak için BOOT'u 3 sn basılı tutun.", + "buddy_legacy_firmware_hint": "Eski donanım yazılımı uyumluluk modunda bağlandı. Eşleştirme ve daha zengin durum panelleri için en yeni Buddy donanım yazılımını yükleyin.", "buddy_status_reconnecting": "%d sn sonra yeniden bağlanıyor…", "buddy_status_no_selection": "Buddy seçilmedi", "buddy_status_searching_selected": "Seçilen Buddy bekleniyor…", diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index a7611a51..42175289 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -1332,12 +1332,22 @@ private struct BuddyPage: View { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.red) - Text(bridge.lastError ?? l10n["buddy_pair_rejected_hint"]) + Text(l10n["buddy_pair_rejected_hint"]) .font(.caption) .foregroundStyle(.red) } } + if bridge.usesLegacyPairingFallback { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(l10n["buddy_legacy_firmware_hint"]) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Button { bridge.stop() if enabled { bridge.start() } diff --git a/Sources/CodeIslandCore/ESP32Protocol.swift b/Sources/CodeIslandCore/ESP32Protocol.swift index c3049967..329d4eb6 100644 --- a/Sources/CodeIslandCore/ESP32Protocol.swift +++ b/Sources/CodeIslandCore/ESP32Protocol.swift @@ -82,6 +82,7 @@ public enum ESP32Protocol { public static let eventFrameMarker: UInt8 = 0xF7 public static let timeHintFrameMarker: UInt8 = 0xF6 public static let toolHistoryFrameMarker: UInt8 = 0xF5 + public static let maxToolHistoryNameBytes = 11 public static let approveCurrentPermissionMarker: UInt8 = 0xF0 public static let denyCurrentPermissionMarker: UInt8 = 0xF1 public static let skipCurrentQuestionMarker: UInt8 = 0xF2 @@ -99,6 +100,7 @@ public enum ESP32Protocol { public static let pairAcceptedMarker: UInt8 = 0xE0 public static let pairRejectedMarker: UInt8 = 0xE1 public static let pairPendingMarker: UInt8 = 0xE2 + public static let pairLegacyFallbackSeconds: Double = 2.5 public static let pairConfirmTimeoutSeconds: Int = 30 public static let minBrightnessPercent: UInt8 = 10 @@ -521,7 +523,7 @@ public struct BuddyToolHistoryPayload: Equatable, Sendable { var data = Data() data.append(ESP32Protocol.toolHistoryFrameMarker) data.append(index) - let nameBytes = Array(toolName.utf8.prefix(11)) + let nameBytes = Array(toolName.utf8.prefix(ESP32Protocol.maxToolHistoryNameBytes)) let flags = (success ? 0x80 : 0x00) | UInt8(nameBytes.count) data.append(flags) data.append(contentsOf: nameBytes) @@ -529,6 +531,15 @@ public struct BuddyToolHistoryPayload: Equatable, Sendable { } } +/// Tool history clear frame for Buddy (0xF5, index 0, len 0). +public struct BuddyToolHistoryClearPayload: Equatable, Sendable { + public init() {} + + public func encode() -> Data { + Data([ESP32Protocol.toolHistoryFrameMarker, 0, 0]) + } +} + /// Pair request frame (Mac → Buddy, 0xE0). /// `hostId` is a stable 6-byte identifier unique to this Mac instance. public struct BuddyPairRequestPayload: Equatable, Sendable { diff --git a/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift b/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift index ee8ac6bc..a504398d 100644 --- a/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift +++ b/Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift @@ -261,7 +261,14 @@ final class ESP32ProtocolTests: XCTestCase { let frame = BuddyToolHistoryPayload(index: 0, success: true, toolName: long) let data = frame.encode() let nameLen = data[2] & 0x7F - XCTAssertEqual(nameLen, 11) + XCTAssertEqual(nameLen, UInt8(ESP32Protocol.maxToolHistoryNameBytes)) + } + + func testEncodeToolHistoryClearFrame() { + XCTAssertEqual( + Array(BuddyToolHistoryClearPayload().encode()), + [ESP32Protocol.toolHistoryFrameMarker, 0, 0] + ) } // MARK: - Pair request frame encoding diff --git a/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift b/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift index 8ba5f878..a5513d5c 100644 --- a/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift +++ b/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift @@ -133,4 +133,21 @@ final class AppStatePrimarySourceTests: XCTestCase { "No-session state must show user-configured default mascot on Buddy") XCTAssertEqual(frame.status, .idle) } + + func testESP32StatsUseGlobalToolCountAcrossSessions() { + let appState = AppState() + + var first = SessionSnapshot() + first.recordTool("Read", description: nil, success: true, agentType: nil, maxHistory: 20) + first.recordTool("Edit", description: nil, success: true, agentType: nil, maxHistory: 20) + appState.sessions["s1"] = first + + var second = SessionSnapshot() + second.recordTool("Bash", description: nil, success: false, agentType: nil, maxHistory: 20) + appState.sessions["s2"] = second + appState.refreshDerivedState() + + let stats = appState.esp32StatsPayload(session: second) + XCTAssertEqual(stats.toolCallCount, 3) + } } diff --git a/hardware/README.md b/hardware/README.md index 1412cb7f..8f3a0ce1 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -6,7 +6,8 @@ Buddy 是 [CodeIsland](https://github.com/wxtsky/CodeIsland) 的硬件外设功 - **空闲(idle)→ Sleep 场景**:吉祥物闭眼休眠 - **处理中(processing / running)→ Work 场景**:吉祥物在敲代码 -- **等待批准 / 等待回答(waitApproval / waitQuestion)→ Alert 场景**:吉祥物呼叫你 +- **等待批准(waitApproval)→ Alert 场景**:吉祥物呼叫你 +- **等待回答(waitQuestion)→ Question 场景**:吉祥物提示你打开问题 未连接 BLE 时,Buddy 会显示引导页(含项目 GitHub 二维码与设备名);长按按键即可切到 Demo 模式,自动轮播全部 16 只吉祥物。 @@ -141,8 +142,9 @@ ESP32-C6 需要 **Arduino-ESP32 v3.0 或更高**。 1. 启动 [CodeIsland](https://github.com/wxtsky/CodeIsland) 主程序(需为支持 ESP32 桥接的版本)。 2. 打开 **Preferences → ESP32 / Buddy** 面板,启用桥接开关。 3. 首次连接时 macOS 会请求蓝牙权限,授权后等待扫描到 `Buddy-XXXXXX`,点击连接。 -4. 触发任意 AI Coding Agent(例如让 Claude Code 跑一条命令),Buddy 屏幕会立即切到对应吉祥物的 **Work** 场景。 -5. 在 macOS 端可远程调节屏幕亮度(10%–100%)与翻转方向(朝上 / 朝下),无需重新烧录。 +4. Buddy 屏幕出现 `Pair?` 后,短按 **BOOT** 确认配对;如果不想配对,长按 **BOOT** 拒绝。 +5. 触发任意 AI Coding Agent(例如让 Claude Code 跑一条命令),Buddy 屏幕会立即切到对应吉祥物的 **Work** 场景。 +6. 在 macOS 端可远程调节屏幕亮度(10%–100%)与翻转方向(朝上 / 朝下),无需重新烧录。 > 一直扫不到设备?请到 **系统设置 → 隐私与安全性 → 蓝牙** 确认 CodeIsland 已被授权,然后关掉再打开 Buddy 桥接开关重新触发扫描。 @@ -154,6 +156,8 @@ ESP32-C6 需要 **Arduino-ESP32 v3.0 或更高**。 | --- | --- | | 短按 BOOT | 切换到下一只吉祥物(Onboard / Demo 模式生效;BLE 已连上时由 Mac 决定显示哪只) | | 长按 ≥ 0.6 秒 | 切换 Demo 模式(自动轮播全部吉祥物) | +| 配对页短按 BOOT | 确认当前 Mac 的配对请求 | +| 配对页长按 BOOT | 拒绝当前 Mac 的配对请求 | --- diff --git a/hardware/hardware.ino b/hardware/hardware.ino index f72da503..8193ce01 100644 --- a/hardware/hardware.ino +++ b/hardware/hardware.ino @@ -717,6 +717,12 @@ class CharCallbacks : public BLECharacteristicCallbacks { if (nameLen > 11) nameLen = 11; portENTER_CRITICAL(&bleMux); if (entryIdx == 0) toolHistCount = 0; + if (entryIdx == 0 && nameLen == 0) { + lastBleData = millis(); + portEXIT_CRITICAL(&bleMux); + Serial.println("[BLE] Tool history cleared"); + return; + } if (toolHistCount < MAX_TOOL_HISTORY) { ToolHistEntry& entry = toolHistory[toolHistCount]; memset(entry.name, 0, sizeof(entry.name)); From 029eb49ce1e917de8452989bd68c7c6ad5c5583d Mon Sep 17 00:00:00 2001 From: lakphy Date: Fri, 15 May 2026 02:12:51 +0800 Subject: [PATCH 5/7] feat: implement write frame queue management for Buddy BLE communication --- Sources/CodeIsland/ESP32BridgeManager.swift | 49 ++++++++-- Sources/CodeIsland/ESP32StatePublisher.swift | 2 +- hardware/hardware.ino | 96 +++++++++++++++----- 3 files changed, 114 insertions(+), 33 deletions(-) diff --git a/Sources/CodeIsland/ESP32BridgeManager.swift b/Sources/CodeIsland/ESP32BridgeManager.swift index f632fd45..e5bed473 100644 --- a/Sources/CodeIsland/ESP32BridgeManager.swift +++ b/Sources/CodeIsland/ESP32BridgeManager.swift @@ -87,6 +87,8 @@ final class ESP32BridgeManager: NSObject { private var reconnectTimer: Timer? private var discoveryActive = false private var discoveryPruneTimer: Timer? + private static let maxPendingWriteFrames = 64 + private var pendingWriteQueue: [Data] = [] /// Stable 6-byte identifier for this Mac, used in the application-layer /// pairing handshake so Buddy can distinguish paired hosts. @ObservationIgnored @@ -147,6 +149,7 @@ final class ESP32BridgeManager: NSObject { writeChar = nil notifyChar = nil notifySubscriptionReady = false + resetPendingWrites() connectedPeripheralName = nil usesLegacyPairingFallback = false status = .off @@ -202,6 +205,7 @@ final class ESP32BridgeManager: NSObject { peripheral = nil writeChar = nil notifyChar = nil + resetPendingWrites() connectedPeripheralName = nil } reconnectAttempt = 0 @@ -213,6 +217,7 @@ final class ESP32BridgeManager: NSObject { func forgetSelection() { cancelReconnectTimer() cancelPairingTimers() + resetPendingWrites() forgetting = true // Tell Buddy to clear its paired-host record before we drop the link. @@ -235,6 +240,7 @@ final class ESP32BridgeManager: NSObject { writeChar = nil notifyChar = nil notifySubscriptionReady = false + resetPendingWrites() connectedPeripheralName = nil usesLegacyPairingFallback = false selectedBuddyIdentifier = nil @@ -300,22 +306,18 @@ final class ESP32BridgeManager: NSObject { } private func send(_ data: Data) { - guard let peripheral, let writeChar, status == .connected else { return } - peripheral.writeValue(data, for: writeChar, type: .withoutResponse) + guard peripheral != nil, writeChar != nil, status == .connected else { return } + enqueueWrite(data) } /// Write Buddy screen brightness. No-op when not connected. func sendBrightness(percent: Double) { - guard let peripheral, let writeChar, status == .connected else { return } - let data = BuddyBrightnessPayload(percent: percent).encode() - peripheral.writeValue(data, for: writeChar, type: .withoutResponse) + send(BuddyBrightnessPayload(percent: percent).encode()) } /// Write Buddy screen orientation. No-op when not connected. func sendScreenOrientation(_ orientation: BuddyScreenOrientation) { - guard let peripheral, let writeChar, status == .connected else { return } - let data = BuddyScreenOrientationPayload(orientation: orientation).encode() - peripheral.writeValue(data, for: writeChar, type: .withoutResponse) + send(BuddyScreenOrientationPayload(orientation: orientation).encode()) } // MARK: - Internals @@ -327,6 +329,28 @@ final class ESP32BridgeManager: NSObject { } } + private func enqueueWrite(_ data: Data) { + pendingWriteQueue.append(data) + if pendingWriteQueue.count > Self.maxPendingWriteFrames { + let overflow = pendingWriteQueue.count - Self.maxPendingWriteFrames + pendingWriteQueue.removeFirst(overflow) + Self.log.debug("Dropped \(overflow) queued Buddy BLE frames under write backpressure") + } + drainPendingWrites() + } + + private func drainPendingWrites() { + guard let peripheral, let writeChar, status == .connected else { return } + while !pendingWriteQueue.isEmpty, peripheral.canSendWriteWithoutResponse { + let data = pendingWriteQueue.removeFirst() + peripheral.writeValue(data, for: writeChar, type: .withoutResponse) + } + } + + private func resetPendingWrites() { + pendingWriteQueue.removeAll(keepingCapacity: false) + } + private static let hostIdDefaultsKey = "buddyHostIdentifier" /// Load or generate a stable 6-byte host identifier persisted in UserDefaults. @@ -649,6 +673,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { self.writeChar = nil self.notifyChar = nil self.notifySubscriptionReady = false + self.resetPendingWrites() self.connectedPeripheralName = nil self.scheduleReconnect() } @@ -664,6 +689,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { self.writeChar = nil self.notifyChar = nil self.notifySubscriptionReady = false + self.resetPendingWrites() self.connectedPeripheralName = nil if self.forgetting { // Link dropped during forget flow (write ACK may never arrive). @@ -790,6 +816,13 @@ extension ESP32BridgeManager: CBPeripheralDelegate { } } + nonisolated func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + Task { @MainActor in + guard self.peripheral?.identifier == peripheral.identifier else { return } + self.drainPendingWrites() + } + } + nonisolated func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { diff --git a/Sources/CodeIsland/ESP32StatePublisher.swift b/Sources/CodeIsland/ESP32StatePublisher.swift index 31859ca1..eb62b331 100644 --- a/Sources/CodeIsland/ESP32StatePublisher.swift +++ b/Sources/CodeIsland/ESP32StatePublisher.swift @@ -95,9 +95,9 @@ final class ESP32StatePublisher { bridge.sendWorkspace(appState.esp32WorkspacePayload(session: session)) appState.esp32MessagePreviewPayloads(session: session).forEach { bridge.sendMessagePreview($0) } bridge.sendModel(appState.esp32ModelPayload(session: session)) + bridge.sendTimeHint(BuddyTimeHintPayload(hour: Calendar.current.component(.hour, from: Date()))) bridge.sendStats(appState.esp32StatsPayload(session: session)) bridge.sendSubagent(appState.esp32SubagentPayload(session: session)) - bridge.sendTimeHint(BuddyTimeHintPayload(hour: Calendar.current.component(.hour, from: Date()))) let toolHistory = appState.esp32ToolHistoryPayloads(session: session) if toolHistory.isEmpty { bridge.sendToolHistoryClear() diff --git a/hardware/hardware.ino b/hardware/hardware.ino index 8193ce01..bad5c1d3 100644 --- a/hardware/hardware.ino +++ b/hardware/hardware.ino @@ -172,6 +172,7 @@ uint8_t toolHistCount = 0; uint8_t heatmap[24] = {0}; uint8_t heatmapSlot = 0; uint8_t bleCurrentHour = 255; +bool heatmapStatsBaselineReady = false; // --- Global bored flag (checked by mascot sleep functions) --- bool globalBored = false; @@ -183,7 +184,7 @@ float globalWorkTimeScale = 1.0f; // --- NVS persistence --- Preferences prefs; unsigned long lastNvsSave = 0; -bool nvsDirty = false; +volatile bool nvsDirty = false; #define NVS_DEBOUNCE_MS 5000 #ifdef BUDDY_OTA_ENABLED @@ -550,9 +551,9 @@ class CharCallbacks : public BLECharacteristicCallbacks { uint8_t percent = clampBuddyBrightness(data[1]); portENTER_CRITICAL(&bleMux); buddyBrightnessPercent = percent; + nvsDirty = true; portEXIT_CRITICAL(&bleMux); lastInteraction = millis(); - nvsDirty = true; Serial.printf("[BLE] Brightness config: %d%%\n", percent); return; } @@ -604,11 +605,17 @@ class CharCallbacks : public BLECharacteristicCallbacks { // Session stats frame (0xFA) if (len >= 6 && data[0] == 0xFA) { + uint8_t loggedActiveSessions; + uint8_t loggedTotalSessions; + uint16_t loggedToolCount; + uint8_t loggedDuration; portENTER_CRITICAL(&bleMux); bleActiveSessionCount = data[1]; bleTotalSessionCount = data[2]; uint16_t newToolCount = ((uint16_t)data[3] << 8) | data[4]; - if (newToolCount > bleToolCallCount) { + if (!heatmapStatsBaselineReady || bleCurrentHour == 255 || newToolCount < bleToolCallCount) { + heatmapStatsBaselineReady = true; + } else if (newToolCount > bleToolCallCount) { uint16_t delta = newToolCount - bleToolCallCount; uint16_t newVal = heatmap[heatmapSlot] + delta; heatmap[heatmapSlot] = (newVal > 255) ? 255 : (uint8_t)newVal; @@ -616,9 +623,13 @@ class CharCallbacks : public BLECharacteristicCallbacks { bleToolCallCount = newToolCount; bleSessionDurationMin = data[5]; lastBleData = millis(); + loggedActiveSessions = bleActiveSessionCount; + loggedTotalSessions = bleTotalSessionCount; + loggedToolCount = bleToolCallCount; + loggedDuration = bleSessionDurationMin; portEXIT_CRITICAL(&bleMux); Serial.printf("[BLE] Stats: sessions=%d/%d tools=%d duration=%dm\n", - bleActiveSessionCount, bleTotalSessionCount, bleToolCallCount, bleSessionDurationMin); + loggedActiveSessions, loggedTotalSessions, loggedToolCount, loggedDuration); return; } @@ -647,13 +658,19 @@ class CharCallbacks : public BLECharacteristicCallbacks { // Time hint frame (0xF6) if (len >= 2 && data[0] == 0xF6) { uint8_t newHour = data[1]; + uint8_t loggedHour; + portENTER_CRITICAL(&bleMux); if (bleCurrentHour != 255 && newHour != bleCurrentHour) { heatmapSlot = newHour % 24; heatmap[heatmapSlot] = 0; + } else if (bleCurrentHour == 255) { + heatmapSlot = newHour % 24; } bleCurrentHour = newHour; lastBleData = millis(); - Serial.printf("[BLE] Hour: %d\n", bleCurrentHour); + loggedHour = bleCurrentHour; + portEXIT_CRITICAL(&bleMux); + Serial.printf("[BLE] Hour: %d\n", loggedHour); return; } @@ -909,14 +926,21 @@ void drawToolTimeline() { // --- Draw heatmap bar (24h activity, bottom of idle screen) --- void drawHeatmapBar() { + uint8_t localHeatmap[24]; + uint8_t localSlot; + portENTER_CRITICAL(&bleMux); + memcpy(localHeatmap, heatmap, sizeof(localHeatmap)); + localSlot = heatmapSlot; + portEXIT_CRITICAL(&bleMux); + bool hasData = false; - for (int i = 0; i < 24; i++) { if (heatmap[i] > 0) { hasData = true; break; } } + for (int i = 0; i < 24; i++) { if (localHeatmap[i] > 0) { hasData = true; break; } } if (!hasData) return; int barY = LCD_H - 24; int slotW = (LCD_W - 4) / 24; for (int i = 0; i < 24; i++) { - uint8_t val = heatmap[(heatmapSlot + 1 + i) % 24]; + uint8_t val = localHeatmap[(localSlot + 1 + i) % 24]; float intensity = val / 255.0f; uint8_t r = (uint8_t)(20 + intensity * 30); uint8_t g = (uint8_t)(40 + intensity * 200); @@ -926,8 +950,8 @@ void drawHeatmapBar() { } // --- Draw celebration animation --- -void drawCelebration(float t, uint8_t mascotIdx) { - float elapsed = (millis() - animStartTime) / 1000.0f; +void drawCelebration(float t, uint8_t mascotIdx, unsigned long animationStartTime) { + float elapsed = (millis() - animationStartTime) / 1000.0f; mascots[mascotIdx].work(t); for (int i = 0; i < 5; i++) { float px = 3.0f + i * 2.5f + sinf(elapsed * 3 + i) * 1.5f; @@ -939,8 +963,8 @@ void drawCelebration(float t, uint8_t mascotIdx) { } // --- Draw frustrated animation --- -void drawFrustrated(float t, uint8_t mascotIdx) { - float elapsed = (millis() - animStartTime) / 1000.0f; +void drawFrustrated(float t, uint8_t mascotIdx, unsigned long animationStartTime) { + float elapsed = (millis() - animationStartTime) / 1000.0f; float shakeX = sinf(elapsed * 30.0f) * (1.0f - elapsed * 0.5f) * 2.0f; setViewportShiftX(shakeX); mascots[mascotIdx].work(t); @@ -1391,15 +1415,27 @@ void loop() { // Check transient animations bool playingTransient = false; - if (pendingAnim != ANIM_NONE && (millis() - animStartTime) < ANIM_DURATION_MS) { + TransientAnim localPendingAnim; + unsigned long localAnimStartTime; + portENTER_CRITICAL(&bleMux); + localPendingAnim = pendingAnim; + localAnimStartTime = animStartTime; + portEXIT_CRITICAL(&bleMux); + if (localPendingAnim != ANIM_NONE && (now - localAnimStartTime) < ANIM_DURATION_MS) { playingTransient = true; - if (pendingAnim == ANIM_CELEBRATE) { - drawCelebration(t, drawIdx); - } else if (pendingAnim == ANIM_FRUSTRATED) { - drawFrustrated(t, drawIdx); + if (localPendingAnim == ANIM_CELEBRATE) { + drawCelebration(t, drawIdx, localAnimStartTime); + } else if (localPendingAnim == ANIM_FRUSTRATED) { + drawFrustrated(t, drawIdx, localAnimStartTime); } } else { - if (pendingAnim != ANIM_NONE) pendingAnim = ANIM_NONE; + if (localPendingAnim != ANIM_NONE) { + portENTER_CRITICAL(&bleMux); + if (pendingAnim == localPendingAnim && animStartTime == localAnimStartTime) { + pendingAnim = ANIM_NONE; + } + portEXIT_CRITICAL(&bleMux); + } // Bored detection for idle mascots if (drawScene == SCENE_SLEEP) { @@ -1460,13 +1496,25 @@ void loop() { tft.drawRGBBitmap(0, 0, canvas.getBuffer(), LCD_W, LCD_H); // NVS debounce write - if (nvsDirty && (now - lastNvsSave) > NVS_DEBOUNCE_MS) { - prefs.putUChar("bright", buddyBrightnessPercent); - prefs.putUChar("orient", buddyScreenOrientation); - nvsDirty = false; - lastNvsSave = now; - Serial.printf("[NVS] Saved brightness=%d%% orientation=%s\n", - buddyBrightnessPercent, buddyOrientationStr(buddyScreenOrientation)); + if ((now - lastNvsSave) > NVS_DEBOUNCE_MS) { + bool shouldSaveNvs = false; + uint8_t nvsBrightness = BUDDY_BRIGHTNESS_DEFAULT_PERCENT; + uint8_t nvsOrientation = BUDDY_SCREEN_UP; + portENTER_CRITICAL(&bleMux); + if (nvsDirty) { + nvsBrightness = buddyBrightnessPercent; + nvsOrientation = buddyScreenOrientation; + nvsDirty = false; + shouldSaveNvs = true; + } + portEXIT_CRITICAL(&bleMux); + if (shouldSaveNvs) { + prefs.putUChar("bright", nvsBrightness); + prefs.putUChar("orient", nvsOrientation); + lastNvsSave = now; + Serial.printf("[NVS] Saved brightness=%d%% orientation=%s\n", + nvsBrightness, buddyOrientationStr(nvsOrientation)); + } } #ifdef BUDDY_OTA_ENABLED From 963473031fb7865a09c15e945206332fb0bd60a5 Mon Sep 17 00:00:00 2001 From: lakphy Date: Fri, 22 May 2026 01:57:58 +0800 Subject: [PATCH 6/7] Improve Buddy Bluetooth recovery and signing --- README.md | 2 +- README.zh-CN.md | 2 +- Sources/CodeIsland/ClineView.swift | 2 + Sources/CodeIsland/ESP32BridgeManager.swift | 50 +++++++++++++++++++++ scripts/build-dmg.sh | 44 ++++++++++++++++-- scripts/dev-hot-restart.sh | 3 ++ 6 files changed, 97 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 33a6b13e..f68895a2 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Requires **macOS 14+** and **Swift 5.9+**. git clone https://github.com/wxtsky/CodeIsland.git cd CodeIsland -# Development (debug build + launch) +# Development (debug build + launch; Buddy Bluetooth needs the .app below) swift build && ./.build/debug/CodeIsland # Release (universal binary: Apple Silicon + Intel) diff --git a/README.zh-CN.md b/README.zh-CN.md index 52429c94..3ac13376 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,7 +81,7 @@ brew install --cask codeisland git clone https://github.com/wxtsky/CodeIsland.git cd CodeIsland -# 开发模式(debug 构建 + 启动) +# 开发模式(debug 构建 + 启动;Buddy 蓝牙需要下面的 .app) swift build && ./.build/debug/CodeIsland # 发布模式(通用二进制:Apple Silicon + Intel) diff --git a/Sources/CodeIsland/ClineView.swift b/Sources/CodeIsland/ClineView.swift index 1c438a70..50123fc6 100644 --- a/Sources/CodeIsland/ClineView.swift +++ b/Sources/CodeIsland/ClineView.swift @@ -277,6 +277,7 @@ struct ClineView: View { } } +#if DEBUG #Preview("ClineView") { HStack(spacing: 20) { ClineView(status: .idle, size: 54) @@ -286,3 +287,4 @@ struct ClineView: View { .padding(24) .background(Color(white: 0.15)) } +#endif diff --git a/Sources/CodeIsland/ESP32BridgeManager.swift b/Sources/CodeIsland/ESP32BridgeManager.swift index e5bed473..7c6e4aef 100644 --- a/Sources/CodeIsland/ESP32BridgeManager.swift +++ b/Sources/CodeIsland/ESP32BridgeManager.swift @@ -100,6 +100,10 @@ final class ESP32BridgeManager: NSObject { private var pairTimeoutTimer: Timer? /// Fires when no pair response arrives at all, which indicates pre-pairing firmware. private var pairLegacyFallbackTimer: Timer? + /// CoreBluetooth can keep an existing manager in `.unauthorized` after the + /// user flips macOS Bluetooth permission back to allowed. Recreate it once + /// in that case so the app can recover without a full relaunch. + private var authorizationRecoveryResetAttempted = false /// Callback fired when Buddy notifies a button press with a /// mascot `sourceId` byte. Nonisolated to allow CoreBluetooth delegate @@ -152,6 +156,9 @@ final class ESP32BridgeManager: NSObject { resetPendingWrites() connectedPeripheralName = nil usesLegacyPairingFallback = false + central?.delegate = nil + central = nil + authorizationRecoveryResetAttempted = false status = .off } @@ -329,6 +336,37 @@ final class ESP32BridgeManager: NSObject { } } + private func recreateCentralAfterAuthorizationRecovery() { + cancelReconnectTimer() + cancelPairingTimers() + if let central { + if central.isScanning { central.stopScan() } + if let peripheral { + central.cancelPeripheralConnection(peripheral) + } + central.delegate = nil + } + central = nil + peripheral = nil + writeChar = nil + notifyChar = nil + notifySubscriptionReady = false + resetPendingWrites() + connectedPeripheralName = nil + usesLegacyPairingFallback = false + ensureCentral() + } + + private static var bluetoothAuthorizationDescription: String { + switch CBManager.authorization { + case .allowedAlways: return "allowedAlways" + case .denied: return "denied" + case .notDetermined: return "notDetermined" + case .restricted: return "restricted" + @unknown default: return "unknown(\(CBManager.authorization.rawValue))" + } + } + private func enqueueWrite(_ data: Data) { pendingWriteQueue.append(data) if pendingWriteQueue.count > Self.maxPendingWriteFrames { @@ -612,6 +650,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { Task { @MainActor in switch central.state { case .poweredOn: + self.authorizationRecoveryResetAttempted = false self.lastError = nil if self.discoveryActive { self.startDiscovery() @@ -622,6 +661,17 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { self.status = .poweredOff self.lastError = "Bluetooth is off" case .unauthorized: + let authorization = Self.bluetoothAuthorizationDescription + let bundleId = Bundle.main.bundleIdentifier ?? "nil" + let bundlePath = Bundle.main.bundlePath + Self.log.error("Bluetooth unauthorized: authorization=\(authorization, privacy: .public) bundle=\(bundleId, privacy: .public) path=\(bundlePath, privacy: .public)") + if CBManager.authorization == .allowedAlways, + !self.authorizationRecoveryResetAttempted { + self.authorizationRecoveryResetAttempted = true + Self.log.info("Bluetooth authorization is allowed again; recreating CBCentralManager") + self.recreateCentralAfterAuthorizationRecovery() + return + } self.status = .poweredOff self.lastError = "Bluetooth permission denied" case .unsupported: diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index c26dd43f..13c29ed1 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +# Ensure Xcode.app toolchain is used even if xcode-select points at CLT. +if [ -d /Applications/Xcode.app/Contents/Developer ]; then + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer +fi + # Usage: [BUILD_ARCH=universal|arm64] ./scripts/build-dmg.sh # Example: ./scripts/build-dmg.sh 1.0.7 # Example: BUILD_ARCH=arm64 SKIP_SIGN=1 SKIP_NOTARIZE=1 ./scripts/build-dmg.sh 1.0.7 @@ -138,8 +143,37 @@ echo "==> App bundle assembled at $APP_DIR" # Override the identity with SIGN_IDENTITY=... if you have a different cert. # --------------------------------------------------------------------------- SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application: xuteng wang (K46MBL36P8)}" +APP_SIGNED=false + +adhoc_sign_app_for_local_permissions() { + echo "==> Ad-hoc signing app with local entitlements" + SPARKLE_FW="$CONTENTS_DIR/Frameworks/Sparkle.framework" + SPARKLE_B="$SPARKLE_FW/Versions/B" + + for xpc in "$SPARKLE_B"/XPCServices/*.xpc; do + [ -e "$xpc" ] || continue + codesign --force --options runtime --sign - "$xpc" + done + [ -e "$SPARKLE_B/Autoupdate" ] && \ + codesign --force --options runtime --sign - "$SPARKLE_B/Autoupdate" + [ -d "$SPARKLE_B/Updater.app" ] && \ + codesign --force --options runtime --sign - "$SPARKLE_B/Updater.app" + codesign --force --options runtime --sign - "$SPARKLE_FW" + + for helper in "$CONTENTS_DIR"/Helpers/*; do + [ -f "$helper" ] || continue + codesign --force --options runtime --sign - "$helper" + done + + codesign --force --options runtime \ + --entitlements "$REPO_ROOT/CodeIsland.entitlements" \ + --sign - \ + "$APP_DIR" +} + if [ "${SKIP_SIGN:-0}" = "1" ]; then - echo "==> SKIP_SIGN=1 — leaving adhoc signature" + echo "==> SKIP_SIGN=1 — skipping Developer ID signing" + adhoc_sign_app_for_local_permissions elif security find-identity -v -p codesigning | grep -q "$(printf '%s' "$SIGN_IDENTITY" | sed 's/[][\\.^$*/]/\\&/g')"; then echo "==> Signing with '$SIGN_IDENTITY' (inside-out for Sparkle, then outer bundle)" SPARKLE_FW="$CONTENTS_DIR/Frameworks/Sparkle.framework" @@ -178,9 +212,11 @@ elif security find-identity -v -p codesigning | grep -q "$(printf '%s' "$SIGN_ID echo "==> Verifying nested signatures" codesign --verify --deep --strict --verbose=2 "$APP_DIR" + APP_SIGNED=true else - echo "==> Developer ID identity '$SIGN_IDENTITY' not in keychain — leaving adhoc signature" + echo "==> Developer ID identity '$SIGN_IDENTITY' not in keychain — using ad-hoc signing" echo " (install your Developer ID cert or set SIGN_IDENTITY=...)" + adhoc_sign_app_for_local_permissions fi echo "==> Creating DMG" @@ -207,7 +243,7 @@ create-dmg \ # fail with "An error occurred while running the updater" in that state. # Stapler still works without this step, but Sparkle's helper handoff is # happier when the container is signed. -if [ "${SKIP_SIGN:-0}" != "1" ] && [[ "$SIGN_IDENTITY" != "-" ]]; then +if [ "$APP_SIGNED" = true ]; then echo "==> Code-signing the DMG container" codesign --force --sign "$SIGN_IDENTITY" --timestamp "$OUTPUT_DMG" fi @@ -220,7 +256,7 @@ fi NOTARY_PROFILE="${NOTARY_PROFILE:-CodeIsland}" if [ "${SKIP_NOTARIZE:-0}" = "1" ]; then echo "==> SKIP_NOTARIZE=1 — release DMG is not notarized" -elif [ "${SKIP_SIGN:-0}" = "1" ]; then +elif [ "$APP_SIGNED" != true ]; then echo "==> Skipping notarization (app was not Developer-ID signed)" else echo "==> Submitting to Apple notary service (profile '$NOTARY_PROFILE')" diff --git a/scripts/dev-hot-restart.sh b/scripts/dev-hot-restart.sh index a6b6ba2d..ef7d6a96 100755 --- a/scripts/dev-hot-restart.sh +++ b/scripts/dev-hot-restart.sh @@ -150,6 +150,9 @@ quit_app() { } launch_app() { + if [[ "$APP_PATH" != *.app/Contents/MacOS/* ]]; then + log "NOTE: launching a bare executable; Buddy Bluetooth requires a signed .app bundle with Bluetooth entitlements" + fi if [[ -n "$SOCKET_PATH" ]]; then log "Launching app with CODEISLAND_SOCKET_PATH=$SOCKET_PATH" CODEISLAND_SOCKET_PATH="$SOCKET_PATH" "$APP_PATH" & From cf83ff1ad1c3ac1056c3433ab5a809105c7421c9 Mon Sep 17 00:00:00 2001 From: lakphy Date: Fri, 22 May 2026 02:28:21 +0800 Subject: [PATCH 7/7] Fix Buddy recovery edge cases --- Sources/CodeIsland/ESP32BridgeManager.swift | 231 ++++++++++++++---- Sources/CodeIsland/ESP32StatePublisher.swift | 45 +++- Sources/CodeIslandCore/ESP32Protocol.swift | 5 +- .../AppStatePrimarySourceTests.swift | 31 +++ .../ESP32BridgeManagerQueueTests.swift | 47 ++++ hardware/hardware.ino | 55 ++++- 6 files changed, 353 insertions(+), 61 deletions(-) create mode 100644 Tests/CodeIslandTests/ESP32BridgeManagerQueueTests.swift diff --git a/Sources/CodeIsland/ESP32BridgeManager.swift b/Sources/CodeIsland/ESP32BridgeManager.swift index 7c6e4aef..08e973d0 100644 --- a/Sources/CodeIsland/ESP32BridgeManager.swift +++ b/Sources/CodeIsland/ESP32BridgeManager.swift @@ -5,6 +5,62 @@ import os import Security import CodeIslandCore +enum BuddyWritePriority: Int, Comparable { + case auxiliary = 0 + case normal = 1 + case primary = 2 + case control = 3 + + static func < (lhs: BuddyWritePriority, rhs: BuddyWritePriority) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +struct BuddyQueuedWrite: Equatable { + let data: Data + let priority: BuddyWritePriority +} + +struct BuddyWriteQueue { + private let capacity: Int + private var frames: [BuddyQueuedWrite] = [] + + init(capacity: Int) { + self.capacity = max(1, capacity) + } + + var count: Int { frames.count } + var isEmpty: Bool { frames.isEmpty } + var contents: [BuddyQueuedWrite] { frames } + + mutating func append(_ data: Data, priority: BuddyWritePriority) -> Int { + frames.append(BuddyQueuedWrite(data: data, priority: priority)) + var dropped = 0 + while frames.count > capacity { + frames.remove(at: lowestPriorityOldestIndex()) + dropped += 1 + } + return dropped + } + + mutating func popFirst() -> BuddyQueuedWrite? { + guard !frames.isEmpty else { return nil } + return frames.removeFirst() + } + + mutating func removeAll(keepingCapacity keepCapacity: Bool = false) { + frames.removeAll(keepingCapacity: keepCapacity) + } + + private func lowestPriorityOldestIndex() -> Int { + var result = frames.startIndex + for index in frames.indices.dropFirst() where frames[index].priority < frames[result].priority { + result = index + } + return result + } +} + /// Connection lifecycle state for the Buddy Bluetooth bridge. enum ESP32BridgeStatus: Equatable { case off // user has disabled the bridge @@ -52,7 +108,8 @@ struct DiscoveredBuddy: Identifiable, Equatable { /// bridge auto-reconnects to it on next launch (and ignores other Buddies /// in range). /// -/// Writes use `.withoutResponse` to match the firmware's `WRITE_NR` property. +/// Streaming writes use `.withoutResponse`; pairing uses `.withResponse` when +/// the firmware advertises `WRITE`, so delivery failures don't look like legacy firmware. /// The notify characteristic delivers 1-byte button events carrying the /// currently displayed mascot's `sourceId` – dispatched to /// `ESP32FocusCoordinator`. @@ -88,7 +145,7 @@ final class ESP32BridgeManager: NSObject { private var discoveryActive = false private var discoveryPruneTimer: Timer? private static let maxPendingWriteFrames = 64 - private var pendingWriteQueue: [Data] = [] + private var pendingWriteQueue = BuddyWriteQueue(capacity: ESP32BridgeManager.maxPendingWriteFrames) /// Stable 6-byte identifier for this Mac, used in the application-layer /// pairing handshake so Buddy can distinguish paired hosts. @ObservationIgnored @@ -98,8 +155,14 @@ final class ESP32BridgeManager: NSObject { private var forgetting = false /// Fires after `pairConfirmTimeoutSeconds` while Buddy is waiting for BOOT confirmation. private var pairTimeoutTimer: Timer? - /// Fires when no pair response arrives at all, which indicates pre-pairing firmware. - private var pairLegacyFallbackTimer: Timer? + /// Fires when no pair response arrives after the request write is delivered. + private var pairResponseTimer: Timer? + private var pairResponseAllowsLegacyFallback = false + private enum PendingResponseWrite { + case pairRequest + case unpair + } + private var pendingResponseWrite: PendingResponseWrite? /// CoreBluetooth can keep an existing manager in `.unauthorized` after the /// user flips macOS Bluetooth permission back to allowed. Recreate it once /// in that case so the app can recover without a full relaunch. @@ -149,6 +212,7 @@ final class ESP32BridgeManager: NSObject { if let peripheral, let central { central.cancelPeripheralConnection(peripheral) } + pendingResponseWrite = nil peripheral = nil writeChar = nil notifyChar = nil @@ -209,6 +273,7 @@ final class ESP32BridgeManager: NSObject { central.cancelPeripheralConnection(peripheral) } if peripheral?.identifier != buddyId { + pendingResponseWrite = nil peripheral = nil writeChar = nil notifyChar = nil @@ -232,8 +297,18 @@ final class ESP32BridgeManager: NSObject { if let peripheral, let writeChar, status == .connected || status == .pairing || status == .pairWaitingConfirm { let unpair = BuddyUnpairPayload(hostId: hostIdentifier) - peripheral.writeValue(unpair.encode(), for: writeChar, type: .withResponse) - Self.log.info("Sent unpair frame (withResponse), will disconnect on ACK") + if writeChar.properties.contains(.write) { + pendingResponseWrite = .unpair + peripheral.writeValue(unpair.encode(), for: writeChar, type: .withResponse) + Self.log.info("Sent unpair frame (withResponse), will disconnect on ACK") + } else if writeChar.properties.contains(.writeWithoutResponse) { + peripheral.writeValue(unpair.encode(), for: writeChar, type: .withoutResponse) + Self.log.info("Sent unpair frame (withoutResponse), disconnecting without ACK") + completeForget() + } else { + Self.log.error("Buddy write characteristic does not support unpair writes; disconnecting") + completeForget() + } return } completeForget() @@ -264,67 +339,67 @@ final class ESP32BridgeManager: NSObject { /// Write a single frame to Buddy. No-op when not connected. func send(_ frame: MascotFramePayload) { - send(frame.encode()) + send(frame.encode(), priority: .primary) } /// Write a workspace update frame to Buddy. No-op when not connected. func sendWorkspace(_ workspace: BuddyWorkspacePayload) { - send(workspace.encode()) + send(workspace.encode(), priority: .normal) } /// Write a message preview frame to Buddy. No-op when not connected. func sendMessagePreview(_ preview: BuddyMessagePreviewPayload) { - send(preview.encode()) + send(preview.encode(), priority: .auxiliary) } /// Write model info frame to Buddy. No-op when not connected. func sendModel(_ model: BuddyModelPayload) { - send(model.encode()) + send(model.encode(), priority: .normal) } /// Write session stats frame to Buddy. No-op when not connected. func sendStats(_ stats: BuddyStatsPayload) { - send(stats.encode()) + send(stats.encode(), priority: .normal) } /// Write subagent count frame to Buddy. No-op when not connected. func sendSubagent(_ subagent: BuddySubagentPayload) { - send(subagent.encode()) + send(subagent.encode(), priority: .normal) } /// Write event frame to Buddy. No-op when not connected. func sendEvent(_ event: BuddyEventPayload) { - send(event.encode()) + send(event.encode(), priority: .control) } /// Write time hint frame to Buddy. No-op when not connected. func sendTimeHint(_ timeHint: BuddyTimeHintPayload) { - send(timeHint.encode()) + send(timeHint.encode(), priority: .auxiliary) } /// Write tool history entry frame to Buddy. No-op when not connected. func sendToolHistory(_ entry: BuddyToolHistoryPayload) { - send(entry.encode()) + send(entry.encode(), priority: .auxiliary) } /// Clear Buddy's tool history timeline. No-op when not connected. func sendToolHistoryClear() { - send(BuddyToolHistoryClearPayload().encode()) + send(BuddyToolHistoryClearPayload().encode(), priority: .normal) } - private func send(_ data: Data) { + private func send(_ data: Data, priority: BuddyWritePriority) { guard peripheral != nil, writeChar != nil, status == .connected else { return } - enqueueWrite(data) + enqueueWrite(data, priority: priority) } /// Write Buddy screen brightness. No-op when not connected. func sendBrightness(percent: Double) { - send(BuddyBrightnessPayload(percent: percent).encode()) + send(BuddyBrightnessPayload(percent: percent).encode(), priority: .control) } /// Write Buddy screen orientation. No-op when not connected. func sendScreenOrientation(_ orientation: BuddyScreenOrientation) { - send(BuddyScreenOrientationPayload(orientation: orientation).encode()) + send(BuddyScreenOrientationPayload(orientation: orientation).encode(), priority: .control) } // MARK: - Internals @@ -346,6 +421,7 @@ final class ESP32BridgeManager: NSObject { } central.delegate = nil } + pendingResponseWrite = nil central = nil peripheral = nil writeChar = nil @@ -367,12 +443,10 @@ final class ESP32BridgeManager: NSObject { } } - private func enqueueWrite(_ data: Data) { - pendingWriteQueue.append(data) - if pendingWriteQueue.count > Self.maxPendingWriteFrames { - let overflow = pendingWriteQueue.count - Self.maxPendingWriteFrames - pendingWriteQueue.removeFirst(overflow) - Self.log.debug("Dropped \(overflow) queued Buddy BLE frames under write backpressure") + private func enqueueWrite(_ data: Data, priority: BuddyWritePriority) { + let dropped = pendingWriteQueue.append(data, priority: priority) + if dropped > 0 { + Self.log.debug("Dropped \(dropped) low-priority queued Buddy BLE frames under write backpressure") } drainPendingWrites() } @@ -380,8 +454,8 @@ final class ESP32BridgeManager: NSObject { private func drainPendingWrites() { guard let peripheral, let writeChar, status == .connected else { return } while !pendingWriteQueue.isEmpty, peripheral.canSendWriteWithoutResponse { - let data = pendingWriteQueue.removeFirst() - peripheral.writeValue(data, for: writeChar, type: .withoutResponse) + guard let frame = pendingWriteQueue.popFirst() else { break } + peripheral.writeValue(frame.data, for: writeChar, type: .withoutResponse) } } @@ -415,19 +489,32 @@ final class ESP32BridgeManager: NSObject { private func sendPairRequest() { guard let peripheral, let writeChar else { return } let payload = BuddyPairRequestPayload(hostId: hostIdentifier) - peripheral.writeValue(payload.encode(), for: writeChar, type: .withoutResponse) - Self.log.info("Pair request sent") - scheduleLegacyPairFallback() + let data = payload.encode() + if writeChar.properties.contains(.write) { + pendingResponseWrite = .pairRequest + peripheral.writeValue(data, for: writeChar, type: .withResponse) + Self.log.info("Pair request sent (withResponse)") + } else if writeChar.properties.contains(.writeWithoutResponse) { + peripheral.writeValue(data, for: writeChar, type: .withoutResponse) + Self.log.info("Pair request sent (withoutResponse)") + schedulePairResponseTimeout(allowsLegacyFallback: true) + } else { + Self.log.error("Buddy write characteristic does not support writes") + lastError = "Buddy write characteristic does not support writes" + status = .pairRejected + central?.cancelPeripheralConnection(peripheral) + } } - private func scheduleLegacyPairFallback() { - pairLegacyFallbackTimer?.invalidate() - pairLegacyFallbackTimer = Timer.scheduledTimer( - withTimeInterval: ESP32Protocol.pairLegacyFallbackSeconds, + private func schedulePairResponseTimeout(allowsLegacyFallback: Bool) { + pairResponseTimer?.invalidate() + pairResponseAllowsLegacyFallback = allowsLegacyFallback + pairResponseTimer = Timer.scheduledTimer( + withTimeInterval: ESP32Protocol.pairResponseTimeoutSeconds, repeats: false ) { [weak self] _ in Task { @MainActor in - self?.handleLegacyPairFallback() + self?.handlePairResponseTimeout() } } } @@ -449,14 +536,15 @@ final class ESP32BridgeManager: NSObject { pairTimeoutTimer = nil } - private func cancelLegacyPairFallback() { - pairLegacyFallbackTimer?.invalidate() - pairLegacyFallbackTimer = nil + private func cancelPairResponseTimeout() { + pairResponseTimer?.invalidate() + pairResponseTimer = nil + pairResponseAllowsLegacyFallback = false } private func cancelPairingTimers() { cancelPairTimeout() - cancelLegacyPairFallback() + cancelPairResponseTimeout() } private func handlePairTimeout() { @@ -470,14 +558,26 @@ final class ESP32BridgeManager: NSObject { } } - private func handleLegacyPairFallback() { + private func handlePairResponseTimeout() { guard status == .pairing else { return } - Self.log.info("No pair response received; continuing in legacy Buddy firmware compatibility mode") - pairLegacyFallbackTimer = nil - usesLegacyPairingFallback = true - lastError = nil - status = .connected - onConnected?() + pairResponseTimer = nil + if pairResponseAllowsLegacyFallback { + Self.log.info("No pair response received after no-response write; continuing in legacy Buddy firmware compatibility mode") + pairResponseAllowsLegacyFallback = false + usesLegacyPairingFallback = true + lastError = nil + status = .connected + onConnected?() + } else { + Self.log.error("No pair response received from Buddy after acknowledged pair request") + pairResponseAllowsLegacyFallback = false + usesLegacyPairingFallback = false + lastError = "Buddy did not respond to pairing. Reconnect or flash the latest Buddy firmware." + status = .pairRejected + if let peripheral, let central { + central.cancelPeripheralConnection(peripheral) + } + } } private func loadSelectionFromDefaults() { @@ -703,6 +803,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { Task { @MainActor in Self.log.info("Connected, discovering services") self.usesLegacyPairingFallback = false + self.pendingResponseWrite = nil // If discovery is still running we no longer need to scan once // we have the selected device hooked up. if !self.discoveryActive, central.isScanning { @@ -718,6 +819,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { Task { @MainActor in Self.log.error("Failed to connect: \(error?.localizedDescription ?? "unknown")") self.cancelPairingTimers() + self.pendingResponseWrite = nil self.lastError = error?.localizedDescription self.peripheral = nil self.writeChar = nil @@ -735,6 +837,7 @@ extension ESP32BridgeManager: CBCentralManagerDelegate { Task { @MainActor in Self.log.info("Disconnected: \(error?.localizedDescription ?? "peer closed")") self.cancelPairingTimers() + self.pendingResponseWrite = nil self.peripheral = nil self.writeChar = nil self.notifyChar = nil @@ -857,7 +960,37 @@ extension ESP32BridgeManager: CBPeripheralDelegate { didWriteValueFor characteristic: CBCharacteristic, error: Error?) { Task { @MainActor in - if self.forgetting { + guard characteristic.uuid == CBUUID(string: ESP32Protocol.writeCharacteristicUUID) else { + return + } + guard let pending = self.pendingResponseWrite else { + if self.forgetting { + if let error { + Self.log.error("Unpair write ACK error (proceeding anyway): \(error.localizedDescription)") + } + self.completeForget() + } + return + } + self.pendingResponseWrite = nil + switch pending { + case .pairRequest: + if let error { + Self.log.error("Pair request write ACK error: \(error.localizedDescription)") + self.cancelPairingTimers() + self.lastError = "Pair request could not be delivered: \(error.localizedDescription)" + self.usesLegacyPairingFallback = false + self.status = .pairRejected + if let peripheral = self.peripheral, let central = self.central { + central.cancelPeripheralConnection(peripheral) + } + return + } + Self.log.info("Pair request write ACK received; waiting for Buddy pair response") + if self.status == .pairing { + self.schedulePairResponseTimeout(allowsLegacyFallback: false) + } + case .unpair: if let error { Self.log.error("Unpair write ACK error (proceeding anyway): \(error.localizedDescription)") } @@ -901,7 +1034,7 @@ extension ESP32BridgeManager: CBPeripheralDelegate { @MainActor private func handlePairResponse(_ response: BuddyPairResponse) { - cancelLegacyPairFallback() + cancelPairResponseTimeout() switch response { case .accepted: Self.log.info("Pair accepted by Buddy") diff --git a/Sources/CodeIsland/ESP32StatePublisher.swift b/Sources/CodeIsland/ESP32StatePublisher.swift index eb62b331..21d916f2 100644 --- a/Sources/CodeIsland/ESP32StatePublisher.swift +++ b/Sources/CodeIsland/ESP32StatePublisher.swift @@ -24,7 +24,12 @@ final class ESP32StatePublisher { private var screenOrientation: BuddyScreenOrientation = .up private var keepAliveActivity: NSObjectProtocol? private var interactiveRetryTask: Task? - private var lastSentStatus: MascotStatusCode? + private var lastSentDisplay: SentDisplayState? + + private struct SentDisplayState { + let identity: String + let status: MascotStatusCode + } private init() { self.bridge = ESP32BridgeManager.shared @@ -34,6 +39,7 @@ final class ESP32StatePublisher { func attach(_ appState: AppState) { self.appState = appState bridge.onConnected = { [weak self] in + self?.resetEventState() self?.syncConfig() self?.flush(reason: "connected") } @@ -67,7 +73,7 @@ final class ESP32StatePublisher { } } else { endKeepAliveActivity() - lastSentStatus = nil + resetEventState() bridge.stop() } } @@ -83,11 +89,12 @@ final class ESP32StatePublisher { guard bridge.status == .connected else { return } guard bridge.selectedBuddyIdentifier != nil else { return } let session = appState.esp32DisplaySession() + let displayIdentity = appState.esp32DisplayIdentity() let frame = appState.esp32DisplayFrame(session: session) bridge.send(frame) if bridge.usesLegacyPairingFallback { - lastSentStatus = frame.status + lastSentDisplay = SentDisplayState(identity: displayIdentity, status: frame.status) Self.log.debug("push(\(reason), legacy): mascot=\(frame.mascot.sourceName) status=\(frame.status.rawValue) tool=\(frame.toolName ?? "")") return } @@ -107,7 +114,10 @@ final class ESP32StatePublisher { // Detect status transitions for event animations let currentStatus = frame.status - if let prev = lastSentStatus, prev != currentStatus { + if let previous = lastSentDisplay, + previous.identity == displayIdentity, + previous.status != currentStatus { + let prev = previous.status if (prev == .processing || prev == .running) && currentStatus == .idle { if let lastTool = session?.toolHistory.last, !lastTool.success { bridge.sendEvent(.error) @@ -120,11 +130,15 @@ final class ESP32StatePublisher { bridge.sendEvent(.approval) } } - lastSentStatus = currentStatus + lastSentDisplay = SentDisplayState(identity: displayIdentity, status: currentStatus) Self.log.debug("push(\(reason)): mascot=\(frame.mascot.sourceName) status=\(frame.status.rawValue) tool=\(frame.toolName ?? "")") } + private func resetEventState() { + lastSentDisplay = nil + } + private func syncConfig() { bridge.sendBrightness(percent: brightnessPercent) bridge.sendScreenOrientation(screenOrientation) @@ -172,9 +186,26 @@ extension AppState { let messages: [ChatMessage] } - func esp32DisplaySession() -> SessionSnapshot? { + func esp32DisplaySessionId() -> String? { let sid = rotatingSessionId ?? activeSessionId ?? sessions.keys.sorted().first - return sid.flatMap { sessions[$0] } + return sid + } + + func esp32DisplaySession() -> SessionSnapshot? { + esp32DisplaySessionId().flatMap { sessions[$0] } + } + + func esp32DisplayIdentity() -> String { + if let pending = pendingPermission { + return "session:\(pending.event.sessionId ?? activeSessionId ?? "default")" + } + if let pending = pendingQuestion { + return "session:\(pending.event.sessionId ?? activeSessionId ?? "default")" + } + if let sessionId = esp32DisplaySessionId() { + return "session:\(sessionId)" + } + return "fallback:\(SettingsManager.shared.defaultSource)" } private func esp32DisplayContext(session: SessionSnapshot? = nil) -> BuddyDisplayContext { diff --git a/Sources/CodeIslandCore/ESP32Protocol.swift b/Sources/CodeIslandCore/ESP32Protocol.swift index 329d4eb6..2df954f8 100644 --- a/Sources/CodeIslandCore/ESP32Protocol.swift +++ b/Sources/CodeIslandCore/ESP32Protocol.swift @@ -4,7 +4,7 @@ import Foundation /// /// BLE service / characteristics: /// - Service: `0000beef-0000-1000-8000-00805f9b34fb` -/// - Write (host→Buddy, WRITE_NR): `0000beef-0001-1000-8000-00805f9b34fb` +/// - Write (host→Buddy, WRITE + WRITE_NR): `0000beef-0001-1000-8000-00805f9b34fb` /// - Notify (Buddy→host): `0000beef-0002-1000-8000-00805f9b34fb` /// /// Downlink agent frame (≤ 20 bytes): @@ -100,7 +100,8 @@ public enum ESP32Protocol { public static let pairAcceptedMarker: UInt8 = 0xE0 public static let pairRejectedMarker: UInt8 = 0xE1 public static let pairPendingMarker: UInt8 = 0xE2 - public static let pairLegacyFallbackSeconds: Double = 2.5 + public static let pairResponseTimeoutSeconds: Double = 2.5 + public static let pairLegacyFallbackSeconds: Double = pairResponseTimeoutSeconds public static let pairConfirmTimeoutSeconds: Int = 30 public static let minBrightnessPercent: UInt8 = 10 diff --git a/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift b/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift index a5513d5c..dfeedea8 100644 --- a/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift +++ b/Tests/CodeIslandTests/AppStatePrimarySourceTests.swift @@ -150,4 +150,35 @@ final class AppStatePrimarySourceTests: XCTestCase { let stats = appState.esp32StatsPayload(session: second) XCTAssertEqual(stats.toolCallCount, 3) } + + func testESP32DisplayIdentityStaysStableAcrossStatusAndDefaultMascotChanges() { + UserDefaults.standard.set("codex", forKey: SettingsKey.defaultSource) + + let appState = AppState() + var session = SessionSnapshot() + session.source = "cursor" + session.status = .running + appState.sessions["s1"] = session + appState.activeSessionId = "s1" + + XCTAssertEqual(appState.esp32DisplayIdentity(), "session:s1") + + session.status = .idle + appState.sessions["s1"] = session + + XCTAssertEqual(appState.esp32DisplayIdentity(), "session:s1", + "Completion/error animations should still be scoped to the same session even when idle display falls back to the default mascot") + } + + func testESP32DisplayIdentityChangesWhenDisplayedSessionChanges() { + let appState = AppState() + appState.sessions["s1"] = SessionSnapshot() + appState.sessions["s2"] = SessionSnapshot() + + appState.activeSessionId = "s1" + XCTAssertEqual(appState.esp32DisplayIdentity(), "session:s1") + + appState.activeSessionId = "s2" + XCTAssertEqual(appState.esp32DisplayIdentity(), "session:s2") + } } diff --git a/Tests/CodeIslandTests/ESP32BridgeManagerQueueTests.swift b/Tests/CodeIslandTests/ESP32BridgeManagerQueueTests.swift new file mode 100644 index 00000000..0631e54d --- /dev/null +++ b/Tests/CodeIslandTests/ESP32BridgeManagerQueueTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import CodeIsland + +final class ESP32BridgeManagerQueueTests: XCTestCase { + func testWriteQueueDropsOldAuxiliaryFramesBeforePrimaryFrames() { + var queue = BuddyWriteQueue(capacity: 3) + + XCTAssertEqual(queue.append(Data([0xFB, 0]), priority: .auxiliary), 0) + XCTAssertEqual(queue.append(Data([0xFB, 1]), priority: .auxiliary), 0) + XCTAssertEqual(queue.append(Data([0x01]), priority: .primary), 0) + XCTAssertEqual(queue.append(Data([0x02]), priority: .primary), 1) + + XCTAssertEqual(queue.contents.map(\.data), [ + Data([0xFB, 1]), + Data([0x01]), + Data([0x02]), + ]) + } + + func testWriteQueueDropsNewAuxiliaryFrameWhenExistingFramesAreMoreImportant() { + var queue = BuddyWriteQueue(capacity: 3) + + XCTAssertEqual(queue.append(Data([0x01]), priority: .primary), 0) + XCTAssertEqual(queue.append(Data([0x02]), priority: .primary), 0) + XCTAssertEqual(queue.append(Data([0xF7, 1]), priority: .control), 0) + XCTAssertEqual(queue.append(Data([0xFB, 0]), priority: .auxiliary), 1) + + XCTAssertEqual(queue.contents.map(\.data), [ + Data([0x01]), + Data([0x02]), + Data([0xF7, 1]), + ]) + } + + func testWriteQueuePreservesSendOrderForRetainedFrames() { + var queue = BuddyWriteQueue(capacity: 3) + + _ = queue.append(Data([0xFB, 0]), priority: .auxiliary) + _ = queue.append(Data([0x01]), priority: .primary) + _ = queue.append(Data([0xFD, 1]), priority: .control) + + XCTAssertEqual(queue.popFirst()?.data, Data([0xFB, 0])) + XCTAssertEqual(queue.popFirst()?.data, Data([0x01])) + XCTAssertEqual(queue.popFirst()?.data, Data([0xFD, 1])) + XCTAssertNil(queue.popFirst()) + } +} diff --git a/hardware/hardware.ino b/hardware/hardware.ino index bad5c1d3..104f57aa 100644 --- a/hardware/hardware.ino +++ b/hardware/hardware.ino @@ -130,6 +130,8 @@ static const char CODEISLAND_QR[CODEISLAND_QR_SIZE][CODEISLAND_QR_SIZE + 1] PROG volatile uint8_t bleSourceId = 0; // 0=claude, 1=codex, ... volatile uint8_t bleStatusId = 0; // 0=idle, 1=processing, 2=running, 3=waitApproval, 4=waitQuestion volatile bool bleConnected = false; +volatile uint16_t bleConnId = 0; +volatile bool bleConnIdValid = false; volatile unsigned long lastBleData = 0; volatile uint8_t buddyBrightnessPercent = BUDDY_BRIGHTNESS_DEFAULT_PERCENT; volatile uint8_t buddyScreenOrientation = BUDDY_SCREEN_UP; @@ -414,15 +416,55 @@ int pollButton(unsigned long now) { return btnLongFired ? 0 : 1; } +static void rememberBleConnection(uint16_t connId) { + portENTER_CRITICAL(&bleMux); + bleConnId = connId; + bleConnIdValid = true; + portEXIT_CRITICAL(&bleMux); +} + +static void clearBleConnection() { + portENTER_CRITICAL(&bleMux); + bleConnIdValid = false; + portEXIT_CRITICAL(&bleMux); +} + +static bool currentBleConnectionId(uint16_t* outConnId) { + portENTER_CRITICAL(&bleMux); + bool valid = bleConnIdValid; + uint16_t connId = bleConnId; + portEXIT_CRITICAL(&bleMux); + if (!valid) return false; + *outConnId = connId; + return true; +} + +static void disconnectCurrentClient(const char* reason) { + uint16_t connId = 0; + if (!currentBleConnectionId(&connId)) { + Serial.printf("[BLE] Cannot disconnect client (%s): no active connId\n", reason); + return; + } + BLEServer* server = BLEDevice::getServer(); + if (!server) { + Serial.printf("[BLE] Cannot disconnect client (%s): server unavailable\n", reason); + return; + } + server->disconnect(connId); + Serial.printf("[BLE] Disconnecting client (%s, connId=%u)\n", reason, connId); +} + // --- BLE Callbacks --- class ServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* pServer) override { + rememberBleConnection(pServer->getConnId()); bleConnected = true; pairAuthenticated = false; Serial.println("[BLE] Connected, waiting for pair handshake..."); } void onDisconnect(BLEServer* pServer) override { bleConnected = false; + clearBleConnection(); pairAuthenticated = false; if (appMode == MODE_PAIR_CONFIRM) { appMode = MODE_ONBOARD; @@ -431,6 +473,14 @@ class ServerCallbacks : public BLEServerCallbacks { Serial.println("[BLE] Disconnected, re-advertising..."); BLEDevice::startAdvertising(); } +#if defined(CONFIG_BLUEDROID_ENABLED) + void onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) override { + rememberBleConnection(param->connect.conn_id); + } + void onDisconnect(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) override { + clearBleConnection(); + } +#endif }; // --- Pairing helpers (called from BLE callback context) --- @@ -1227,7 +1277,7 @@ void loop() { appMode = MODE_ONBOARD; portEXIT_CRITICAL(&bleMux); if (bleConnected) { - BLEDevice::getServer()->disconnect(0); + disconnectCurrentClient("factory reset pairing"); } } } @@ -1330,8 +1380,7 @@ void loop() { if (pairRejectPending && (now - pairRejectTime) > PAIR_REJECT_DELAY_MS) { pairRejectPending = false; if (bleConnected) { - BLEDevice::getServer()->disconnect(0); - Serial.println("[PAIR] Disconnecting rejected client"); + disconnectCurrentClient("pair rejected"); } }