Skip to content

Commit 1dd4dea

Browse files
committed
feat(buddy): let Mac drive task timer via 0xF2 frame
Mac now owns the task timer instead of the firmware estimating it. A new downlink task frame (0xF2) carries a rolling taskId + elapsed seconds; the firmware adopts the elapsed value as a baseline and ticks locally, re-syncing on taskId change (also recovers correctly across reconnects). Removes the firmware self-timing block and keys task boundaries on the displayed session (fixes timer not resetting when switching sessions of the same source).
1 parent 7aae400 commit 1dd4dea

5 files changed

Lines changed: 117 additions & 17 deletions

File tree

Sources/CodeIsland/ESP32BridgeManager.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,11 @@ final class ESP32BridgeManager: NSObject {
377377
send(timeHint.encode(), priority: .auxiliary)
378378
}
379379

380+
/// Write task timer frame to Buddy (Mac-authoritative timing). No-op when not connected.
381+
func sendTask(_ task: BuddyTaskPayload) {
382+
send(task.encode(), priority: .normal)
383+
}
384+
380385
/// Write tool history entry frame to Buddy. No-op when not connected.
381386
func sendToolHistory(_ entry: BuddyToolHistoryPayload) {
382387
send(entry.encode(), priority: .auxiliary)

Sources/CodeIsland/ESP32StatePublisher.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,23 @@ final class ESP32StatePublisher {
2525
private var keepAliveActivity: NSObjectProtocol?
2626
private var interactiveRetryTask: Task<Void, Never>?
2727
private var lastSentDisplay: SentDisplayState?
28+
private var currentTask: ActiveTask?
29+
private var nextTaskId: UInt8 = 1 // rolling 1..255, skips 0 (0 = no active task)
2830

2931
private struct SentDisplayState {
3032
let identity: String
3133
let status: MascotStatusCode
3234
}
3335

36+
/// Tracks the currently-running task so the publisher (not the firmware) owns
37+
/// the timer. A task starts when the displayed context becomes busy and ends
38+
/// when it returns to idle or the displayed session changes.
39+
private struct ActiveTask {
40+
let identity: String
41+
let startedAt: Date
42+
let id: UInt8
43+
}
44+
3445
private init() {
3546
self.bridge = ESP32BridgeManager.shared
3647
}
@@ -105,6 +116,7 @@ final class ESP32StatePublisher {
105116
bridge.sendTimeHint(BuddyTimeHintPayload(hour: Calendar.current.component(.hour, from: Date())))
106117
bridge.sendStats(appState.esp32StatsPayload(session: session))
107118
bridge.sendSubagent(appState.esp32SubagentPayload(session: session))
119+
bridge.sendTask(taskPayload(displayIdentity: displayIdentity, status: frame.status))
108120
let toolHistory = appState.esp32ToolHistoryPayloads(session: session)
109121
if toolHistory.isEmpty {
110122
bridge.sendToolHistoryClear()
@@ -135,6 +147,28 @@ final class ESP32StatePublisher {
135147
Self.log.debug("push(\(reason)): mascot=\(frame.mascot.sourceName) status=\(frame.status.rawValue) tool=\(frame.toolName ?? "")")
136148
}
137149

150+
/// Resolve the task-timer frame for the current display. Starts a fresh task
151+
/// (new rolling id, reset clock) when the displayed context becomes busy or the
152+
/// displayed session changes; clears it on idle. `currentTask` is intentionally
153+
/// preserved across reconnects so the elapsed time keeps counting from the real
154+
/// start instead of restarting at 0.
155+
private func taskPayload(displayIdentity: String, status: MascotStatusCode) -> BuddyTaskPayload {
156+
guard status != .idle else {
157+
currentTask = nil
158+
return BuddyTaskPayload(taskId: 0, elapsedSeconds: 0)
159+
}
160+
let task: ActiveTask
161+
if let existing = currentTask, existing.identity == displayIdentity {
162+
task = existing
163+
} else {
164+
task = ActiveTask(identity: displayIdentity, startedAt: Date(), id: nextTaskId)
165+
currentTask = task
166+
nextTaskId = nextTaskId == 255 ? 1 : nextTaskId + 1
167+
}
168+
let elapsed = Int(Date().timeIntervalSince(task.startedAt))
169+
return BuddyTaskPayload(taskId: task.id, elapsedSeconds: elapsed)
170+
}
171+
138172
private func resetEventState() {
139173
lastSentDisplay = nil
140174
}

Sources/CodeIslandCore/ESP32Protocol.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ import Foundation
1818
/// byte[1] = workspaceLen (0..18)
1919
/// byte[2..] = workspace UTF-8 (truncated to 18 bytes)
2020
///
21+
/// Downlink task timer frame (4 bytes):
22+
/// byte[0] = 0xF2 (task frame marker — Mac is the timing authority)
23+
/// byte[1] = taskId (rolling 1..255; 0 = no active task)
24+
/// byte[2] = elapsedSeconds high byte
25+
/// byte[3] = elapsedSeconds low byte (uint16, seconds the current task has been running)
26+
/// NOTE: downlink 0xF2 (task) shares its byte value with the *uplink-only* control
27+
/// opcode 0xF2 (skipCurrentQuestion, Buddy→Mac notify). They never collide because
28+
/// they travel on different characteristics/directions.
29+
///
2130
/// Downlink message preview frame (≤ 20 bytes):
2231
/// byte[0] = 0xFB
2332
/// byte[1] = messageIndex (0-based)
@@ -81,6 +90,7 @@ public enum ESP32Protocol {
8190
public static let subagentFrameMarker: UInt8 = 0xF8
8291
public static let eventFrameMarker: UInt8 = 0xF7
8392
public static let timeHintFrameMarker: UInt8 = 0xF6
93+
public static let taskFrameMarker: UInt8 = 0xF2
8494
public static let toolHistoryFrameMarker: UInt8 = 0xF5
8595
public static let maxToolHistoryNameBytes = 11
8696
public static let approveCurrentPermissionMarker: UInt8 = 0xF0
@@ -508,6 +518,31 @@ public struct BuddyTimeHintPayload: Equatable, Sendable {
508518
}
509519
}
510520

521+
/// Task timer frame for Buddy (0xF2).
522+
///
523+
/// Mac is the authoritative timekeeper: `taskId` lets the firmware detect a new
524+
/// task and re-baseline its local clock, `elapsedSeconds` is how long the current
525+
/// task has been running. A `taskId` of 0 means there is no active task (the
526+
/// firmware hides the timer).
527+
public struct BuddyTaskPayload: Equatable, Sendable {
528+
public let taskId: UInt8 // 0 = no active task
529+
public let elapsedSeconds: UInt16
530+
531+
public init(taskId: UInt8, elapsedSeconds: Int) {
532+
self.taskId = taskId
533+
self.elapsedSeconds = UInt16(min(65535, max(0, elapsedSeconds)))
534+
}
535+
536+
public func encode() -> Data {
537+
Data([
538+
ESP32Protocol.taskFrameMarker,
539+
taskId,
540+
UInt8(elapsedSeconds >> 8),
541+
UInt8(elapsedSeconds & 0xFF),
542+
])
543+
}
544+
}
545+
511546
/// Tool history entry frame for Buddy (0xF5).
512547
public struct BuddyToolHistoryPayload: Equatable, Sendable {
513548
public let index: UInt8

Tests/CodeIslandCoreTests/ESP32ProtocolTests.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ final class ESP32ProtocolTests: XCTestCase {
8585
XCTAssertEqual(BuddyBrightnessPayload(percent: Double.nan).percent, ESP32Protocol.defaultBrightnessPercent)
8686
}
8787

88+
func testEncodeTaskFrame() {
89+
let frame = BuddyTaskPayload(taskId: 7, elapsedSeconds: 300)
90+
XCTAssertEqual(Array(frame.encode()), [ESP32Protocol.taskFrameMarker, 7, 0x01, 0x2C])
91+
}
92+
93+
func testTaskFrameClampsAndSplitsElapsedSeconds() {
94+
XCTAssertEqual(BuddyTaskPayload(taskId: 0, elapsedSeconds: 0).elapsedSeconds, 0)
95+
XCTAssertEqual(BuddyTaskPayload(taskId: 1, elapsedSeconds: -5).elapsedSeconds, 0)
96+
XCTAssertEqual(BuddyTaskPayload(taskId: 1, elapsedSeconds: 100_000).elapsedSeconds, 65535)
97+
XCTAssertEqual(Array(BuddyTaskPayload(taskId: 1, elapsedSeconds: 100_000).encode()),
98+
[ESP32Protocol.taskFrameMarker, 1, 0xFF, 0xFF])
99+
}
100+
88101
func testEncodeScreenOrientationConfigFrame() {
89102
XCTAssertEqual(
90103
Array(BuddyScreenOrientationPayload(orientation: .up).encode()),

hardware/hardware.ino

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ static char bleDeviceName[BLE_DEVICE_NAME_LEN] = "Buddy";
7272
// --- Buddy config frames ---
7373
#define BUDDY_BRIGHTNESS_FRAME 0xFE
7474
#define BUDDY_ORIENTATION_FRAME 0xFD
75+
#define BUDDY_TASK_FRAME 0xF2 // Mac-authoritative task timer: [0xF2, taskId, elapsedHi, elapsedLo]
7576
#define BUDDY_BRIGHTNESS_MIN_PERCENT 10
7677
#define BUDDY_BRIGHTNESS_MAX_PERCENT 100
7778
#define BUDDY_BRIGHTNESS_DEFAULT_PERCENT 70
@@ -138,7 +139,7 @@ volatile unsigned long lastBleData = 0;
138139
volatile unsigned long lastWakeActivity = 0;
139140
volatile bool taskTimerActive = false;
140141
volatile unsigned long taskTimerStart = 0;
141-
volatile uint8_t taskTimerSourceId = 0xFF;
142+
volatile uint8_t taskTimerId = 0; // Mac-provided rolling task id (0 = no active task)
142143
volatile uint8_t buddyBrightnessPercent = BUDDY_BRIGHTNESS_DEFAULT_PERCENT;
143144
volatile uint8_t buddyScreenOrientation = BUDDY_SCREEN_UP;
144145
volatile bool buddyOrientationDirty = false;
@@ -337,10 +338,6 @@ bool statusKeepsScreenAwake(uint8_t status) {
337338
return status == 1 || status == 2 || status == 3 || status == 4;
338339
}
339340

340-
bool statusHasTaskTimer(uint8_t status) {
341-
return status == 1 || status == 2 || status == 3 || status == 4;
342-
}
343-
344341
uint8_t clampBuddyBrightness(uint8_t percent) {
345342
if (percent < BUDDY_BRIGHTNESS_MIN_PERCENT) return BUDDY_BRIGHTNESS_MIN_PERCENT;
346343
if (percent > BUDDY_BRIGHTNESS_MAX_PERCENT) return BUDDY_BRIGHTNESS_MAX_PERCENT;
@@ -741,6 +738,32 @@ class CharCallbacks : public BLECharacteristicCallbacks {
741738
return;
742739
}
743740

741+
// Task timer frame (0xF2): Mac is the timing authority. The firmware no longer
742+
// estimates the task start itself — it only adopts the Mac-provided elapsed time
743+
// as a baseline and ticks locally via millis() between updates.
744+
if (len >= 4 && data[0] == BUDDY_TASK_FRAME) {
745+
uint8_t tid = data[1];
746+
uint16_t elapsed = ((uint16_t)data[2] << 8) | data[3];
747+
unsigned long now_t = millis();
748+
portENTER_CRITICAL(&bleMux);
749+
if (tid == 0) {
750+
taskTimerActive = false;
751+
taskTimerStart = 0;
752+
taskTimerId = 0;
753+
} else {
754+
if (tid != taskTimerId) { // new task → re-baseline the local clock
755+
taskTimerId = tid;
756+
unsigned long offset = (unsigned long)elapsed * 1000UL;
757+
taskTimerStart = (now_t >= offset) ? (now_t - offset) : 0;
758+
}
759+
taskTimerActive = true; // same task → keep baseline, tick smoothly
760+
}
761+
lastBleData = now_t;
762+
portEXIT_CRITICAL(&bleMux);
763+
Serial.printf("[BLE] Task: id=%d elapsed=%us\n", tid, elapsed);
764+
return;
765+
}
766+
744767
#ifdef BUDDY_OTA_ENABLED
745768
// OTA SSID frame (0xF4)
746769
if (len >= 2 && data[0] == 0xF4) {
@@ -833,17 +856,7 @@ class CharCallbacks : public BLECharacteristicCallbacks {
833856
portENTER_CRITICAL(&bleMux);
834857
if (bleSourceId != nextSourceId) headerDirty = true;
835858
if (bleStatusId != nextStatusId) infoDirty = true;
836-
if (statusHasTaskTimer(nextStatusId)) {
837-
if (!taskTimerActive || taskTimerSourceId != nextSourceId) {
838-
taskTimerActive = true;
839-
taskTimerStart = now_write;
840-
taskTimerSourceId = nextSourceId;
841-
}
842-
} else {
843-
taskTimerActive = false;
844-
taskTimerStart = 0;
845-
taskTimerSourceId = 0xFF;
846-
}
859+
// Task timer is driven entirely by the Mac via the 0xF2 task frame (see below).
847860
bleSourceId = nextSourceId;
848861
bleStatusId = nextStatusId;
849862
if (statusKeepsScreenAwake(bleStatusId)) {
@@ -1576,7 +1589,7 @@ void loop() {
15761589
pendingAnim = ANIM_NONE;
15771590
taskTimerActive = false;
15781591
taskTimerStart = 0;
1579-
taskTimerSourceId = 0xFF;
1592+
taskTimerId = 0;
15801593
infoDirty = true;
15811594
clearedStaleAgentState = true;
15821595
}

0 commit comments

Comments
 (0)