Upgraded firmware for Simone Giertz's Every Day Calendar that links an ATProto-powered ESP32, for sync with goals.garden 🌱
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add offline sync modes and first-boot calendar import

Adds OFFLINE_SYNC_MODE config option:
- 0: Block button presses when not synced
- 1: Calendar wins - local changes sync TO goals.garden (default)
- 2: goals.garden wins - remote overwrites local

First boot behavior (no saved goal):
- Request calendar's current LED state via new I2C protocol
- If LEDs blank: select first available goal
- If LEDs set: create "Everyday Calendar" goal and upload completions

New I2C commands for bidirectional state transfer:
- RSP_REQUEST_CAL_STATE: ESP32 requests calendar state
- CMD_CAL_STATE_PART1/2: Calendar sends its LED bitmap

Also adds ATProtoClient::createGoal() for creating new goals.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+371 -26
+188 -8
firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino
··· 36 36 String resolvePDS(const char* identifier); 37 37 void connectATProto(); 38 38 void loadOrSelectGoal(); 39 + void handleFirstBoot(); 40 + bool calendarStateHasLeds(uint32_t* monthStates); 41 + void uploadCalendarStateAsCompletions(uint32_t* monthStates); 39 42 void refreshGoals(); 40 43 void handleGoalSwitch(); 41 44 void handleCalendarButton(uint8_t month, uint8_t day, bool state); ··· 326 329 } 327 330 } 328 331 329 - // Auto-select first goal if none saved 330 - if (goalUri.length() == 0 && !cachedGoals.empty()) { 331 - goalUri = cachedGoals[0].uri; 332 - preferences.putString(NVS_KEY_GOAL_URI, goalUri); 333 - webServer.setCurrentGoal(goalUri, cachedGoals[0].name); 334 - Serial.printf("Auto-selected goal: %s\n", cachedGoals[0].name.c_str()); 332 + // First boot - no saved goal 333 + if (goalUri.length() == 0) { 334 + handleFirstBoot(); 335 335 } 336 336 337 337 if (goalUri.length() == 0) { ··· 339 339 } 340 340 } 341 341 342 + void handleFirstBoot() { 343 + Serial.println(F("First boot detected - checking calendar state")); 344 + 345 + // Request calendar's current LED state 346 + calendarI2C.requestCalendarState(); 347 + 348 + // Wait for calendar to send its state (up to 10 seconds) 349 + unsigned long startTime = millis(); 350 + while (!calendarI2C.hasCalendarState() && millis() - startTime < 10000) { 351 + calendarI2C.update(); 352 + delay(100); 353 + } 354 + 355 + if (!calendarI2C.hasCalendarState()) { 356 + Serial.println(F("Timeout waiting for calendar state")); 357 + // Fall back to selecting first goal if available 358 + if (!cachedGoals.empty()) { 359 + goalUri = cachedGoals[0].uri; 360 + preferences.putString(NVS_KEY_GOAL_URI, goalUri); 361 + webServer.setCurrentGoal(goalUri, cachedGoals[0].name); 362 + Serial.printf("Auto-selected goal: %s\n", cachedGoals[0].name.c_str()); 363 + } 364 + return; 365 + } 366 + 367 + // Get calendar state 368 + uint32_t calState[12]; 369 + calendarI2C.getCalendarState(calState); 370 + calendarI2C.clearCalendarStateRequest(); 371 + 372 + if (calendarStateHasLeds(calState)) { 373 + // Calendar has existing data - create new goal and upload 374 + Serial.println(F("Calendar has existing data - creating goal and uploading")); 375 + 376 + int year = getCurrentYear(); 377 + Goal newGoal = atproto.createGoal("Everyday Calendar", "", year); 378 + 379 + if (newGoal.uri.length() > 0) { 380 + goalUri = newGoal.uri; 381 + preferences.putString(NVS_KEY_GOAL_URI, goalUri); 382 + webServer.setCurrentGoal(goalUri, newGoal.name); 383 + 384 + // Refresh goals to include the new one 385 + refreshGoals(); 386 + 387 + // Upload calendar state as completions 388 + uploadCalendarStateAsCompletions(calState); 389 + 390 + Serial.println(F("Created goal and uploaded calendar state")); 391 + } else { 392 + Serial.println(F("Failed to create goal")); 393 + } 394 + } else { 395 + // Calendar is blank - select first existing goal 396 + Serial.println(F("Calendar is blank - selecting first goal")); 397 + if (!cachedGoals.empty()) { 398 + goalUri = cachedGoals[0].uri; 399 + preferences.putString(NVS_KEY_GOAL_URI, goalUri); 400 + webServer.setCurrentGoal(goalUri, cachedGoals[0].name); 401 + Serial.printf("Auto-selected goal: %s\n", cachedGoals[0].name.c_str()); 402 + } 403 + } 404 + } 405 + 406 + bool calendarStateHasLeds(uint32_t* monthStates) { 407 + for (int month = 0; month < 12; month++) { 408 + if (monthStates[month] != 0) { 409 + return true; 410 + } 411 + } 412 + return false; 413 + } 414 + 415 + void uploadCalendarStateAsCompletions(uint32_t* monthStates) { 416 + int year = getCurrentYear(); 417 + int count = 0; 418 + 419 + for (int month = 0; month < 12; month++) { 420 + uint32_t state = monthStates[month]; 421 + if (state == 0) continue; 422 + 423 + for (int day = 0; day < 31; day++) { 424 + if (state & ((uint32_t)1 << day)) { 425 + // LED is on - create completion 426 + String completedAt = getISO8601Date(year, month + 1, day + 1); 427 + String rkey = atproto.createCompletion(goalUri, year, month + 1, day + 1, completedAt); 428 + if (rkey.length() > 0) { 429 + count++; 430 + } 431 + // Small delay to avoid overwhelming the PDS 432 + delay(50); 433 + } 434 + } 435 + } 436 + 437 + Serial.printf("Uploaded %d completions from calendar\n", count); 438 + } 439 + 342 440 void refreshGoals() { 343 441 int year = getCurrentYear(); 344 442 cachedGoals = atproto.getGoals(year); ··· 392 490 void handleCalendarButton(uint8_t month, uint8_t day, bool state) { 393 491 if (!atprotoConnected || goalUri.length() == 0) { 394 492 Serial.println(F("Not connected or no goal, ignoring button")); 493 + return; 494 + } 495 + 496 + // In mode 0, block button presses when not synced (Jetstream not connected) 497 + if (OFFLINE_SYNC_MODE == 0 && !jetstream.isConnected()) { 498 + Serial.println(F("Offline sync mode 0: blocking button while not synced")); 499 + // Revert the LED state 500 + calendarI2C.setState(month, day, !state); 395 501 return; 396 502 } 397 503 ··· 473 579 void performFullSync() { 474 580 if (!atprotoConnected || goalUri.length() == 0) return; 475 581 476 - Serial.println(F("Performing full sync from goals.garden...")); 582 + int currentYear = getCurrentYear(); 583 + 584 + // Mode 1: Calendar wins - sync local changes TO goals.garden 585 + if (OFFLINE_SYNC_MODE == 1) { 586 + Serial.println(F("Performing full sync (calendar wins)...")); 477 587 478 - int currentYear = getCurrentYear(); 588 + // Get calendar's current state 589 + calendarI2C.requestCalendarState(); 590 + unsigned long startTime = millis(); 591 + while (!calendarI2C.hasCalendarState() && millis() - startTime < 5000) { 592 + calendarI2C.update(); 593 + delay(50); 594 + } 595 + 596 + if (!calendarI2C.hasCalendarState()) { 597 + Serial.println(F("Timeout waiting for calendar state, falling back to goals.garden")); 598 + // Fall through to goals.garden wins 599 + } else { 600 + uint32_t calState[12]; 601 + calendarI2C.getCalendarState(calState); 602 + calendarI2C.clearCalendarStateRequest(); 603 + 604 + // Get goals.garden state 605 + std::vector<Completion> completions = atproto.getCompletions(goalUri, currentYear); 606 + uint32_t remoteState[12] = {0}; 607 + for (const auto& completion : completions) { 608 + if (completion.year == currentYear) { 609 + uint8_t calMonth = completion.month - 1; 610 + uint8_t calDay = completion.day - 1; 611 + if (calMonth < 12 && calDay < 31) { 612 + remoteState[calMonth] |= ((uint32_t)1 << calDay); 613 + } 614 + } 615 + } 616 + 617 + // Sync differences: calendar -> goals.garden 618 + int created = 0, deleted = 0; 619 + for (int month = 0; month < 12; month++) { 620 + uint32_t local = calState[month]; 621 + uint32_t remote = remoteState[month]; 622 + 623 + for (int day = 0; day < 31; day++) { 624 + bool localOn = (local & ((uint32_t)1 << day)) != 0; 625 + bool remoteOn = (remote & ((uint32_t)1 << day)) != 0; 626 + 627 + if (localOn && !remoteOn) { 628 + // Create completion on goals.garden 629 + String completedAt = getISO8601Date(currentYear, month + 1, day + 1); 630 + String rkey = atproto.createCompletion(goalUri, currentYear, month + 1, day + 1, completedAt); 631 + if (rkey.length() > 0) created++; 632 + delay(50); 633 + } else if (!localOn && remoteOn) { 634 + // Delete completion from goals.garden 635 + String rkey = atproto.findCompletionRkey(currentYear, month + 1, day + 1); 636 + if (rkey.length() > 0 && atproto.deleteCompletion(rkey)) { 637 + deleted++; 638 + } 639 + delay(50); 640 + } 641 + } 642 + } 643 + 644 + // Update ESP32's internal state to match calendar 645 + calendarI2C.setFullState(calState); 646 + 647 + // Re-fetch completions to update cache 648 + completions = atproto.getCompletions(goalUri, currentYear); 649 + atproto.cacheCompletions(completions); 650 + calendarI2C.setReady(true); 651 + 652 + Serial.printf("Calendar-wins sync: created %d, deleted %d\n", created, deleted); 653 + return; 654 + } 655 + } 656 + 657 + // Mode 0 or 2 (or fallback): goals.garden wins 658 + Serial.println(F("Performing full sync from goals.garden...")); 479 659 480 660 std::vector<Completion> completions = atproto.getCompletions(goalUri, currentYear); 481 661
+35
firmware/esp32/GoalsGardenSync/atproto_client.cpp
··· 190 190 return goals; 191 191 } 192 192 193 + Goal ATProtoClient::createGoal(const String& name, const String& description, int year) { 194 + Goal goal; 195 + goal.uri = ""; 196 + goal.cid = ""; 197 + goal.name = name; 198 + goal.description = description; 199 + goal.year = year; 200 + 201 + JsonDocument doc; 202 + doc["repo"] = did; 203 + doc["collection"] = "garden.goals.goal"; 204 + 205 + JsonObject record = doc["record"].to<JsonObject>(); 206 + record["$type"] = "garden.goals.goal"; 207 + record["name"] = name; 208 + record["description"] = description; 209 + record["year"] = year; 210 + 211 + String body; 212 + serializeJson(doc, body); 213 + 214 + String response = httpPost("/xrpc/com.atproto.repo.createRecord", body); 215 + if (response.length() == 0) return goal; 216 + 217 + JsonDocument respDoc; 218 + DeserializationError error = deserializeJson(respDoc, response); 219 + if (error) return goal; 220 + 221 + goal.uri = respDoc["uri"].as<String>(); 222 + goal.cid = respDoc["cid"].as<String>(); 223 + 224 + Serial.printf("Created goal: %s\n", goal.uri.c_str()); 225 + return goal; 226 + } 227 + 193 228 std::vector<Completion> ATProtoClient::getCompletions(const String& goalUri, int year) { 194 229 std::vector<Completion> completions; 195 230
+1
firmware/esp32/GoalsGardenSync/atproto_client.h
··· 32 32 33 33 // Goals 34 34 std::vector<Goal> getGoals(int year); 35 + Goal createGoal(const String& name, const String& description, int year); 35 36 36 37 // Completions 37 38 std::vector<Completion> getCompletions(const String& goalUri, int year);
+56
firmware/esp32/GoalsGardenSync/calendar_i2c.cpp
··· 73 73 break; 74 74 } 75 75 76 + case CMD_CAL_STATE_PART1: { 77 + // Calendar sends its state for months 0-5 (24 bytes) 78 + if (numBytes >= 24) { 79 + uint8_t buffer[24]; 80 + for (int i = 0; i < 24; i++) { 81 + buffer[i] = Wire.read(); 82 + } 83 + numBytes -= 24; 84 + memcpy(&_calState[0], buffer, 24); 85 + _calStatePart1Received = true; 86 + } 87 + break; 88 + } 89 + 90 + case CMD_CAL_STATE_PART2: { 91 + // Calendar sends its state for months 6-11 (24 bytes) 92 + if (numBytes >= 24) { 93 + uint8_t buffer[24]; 94 + for (int i = 0; i < 24; i++) { 95 + buffer[i] = Wire.read(); 96 + } 97 + numBytes -= 24; 98 + memcpy(&_calState[6], buffer, 24); 99 + _calStatePart2Received = true; 100 + } 101 + break; 102 + } 103 + 76 104 default: 77 105 break; 78 106 } ··· 156 184 bool CalendarI2C::isReady() { 157 185 return _ready; 158 186 } 187 + 188 + void CalendarI2C::requestCalendarState() { 189 + // Queue a request for calendar to send its state 190 + // This will be sent when calendar next polls us 191 + _calStateRequested = true; 192 + _calStatePart1Received = false; 193 + _calStatePart2Received = false; 194 + memset(_calState, 0, sizeof(_calState)); 195 + 196 + PendingCmd req; 197 + req.data[0] = RSP_REQUEST_CAL_STATE; 198 + req.len = 1; 199 + pendingCmds.push(req); 200 + } 201 + 202 + bool CalendarI2C::hasCalendarState() { 203 + return _calStatePart1Received && _calStatePart2Received; 204 + } 205 + 206 + void CalendarI2C::getCalendarState(uint32_t* monthStates) { 207 + memcpy(monthStates, _calState, sizeof(_calState)); 208 + } 209 + 210 + void CalendarI2C::clearCalendarStateRequest() { 211 + _calStateRequested = false; 212 + _calStatePart1Received = false; 213 + _calStatePart2Received = false; 214 + }
+24 -9
firmware/esp32/GoalsGardenSync/calendar_i2c.h
··· 9 9 #define CALENDAR_I2C_ADDR 0x42 10 10 11 11 // Command codes from master (Calendar -> ESP32) 12 - #define CMD_BUTTON_PRESS 0x01 // Button press: month, day, state 13 - #define CMD_REQUEST_STATE 0x02 // Request full state 14 - #define CMD_PING 0x03 // Ping 12 + #define CMD_BUTTON_PRESS 0x01 // Button press: month, day, state 13 + #define CMD_REQUEST_STATE 0x02 // Request full state from ESP32 14 + #define CMD_PING 0x03 // Ping 15 + #define CMD_CAL_STATE_PART1 0x04 // Calendar sends its state months 0-5 (24 bytes) 16 + #define CMD_CAL_STATE_PART2 0x05 // Calendar sends its state months 6-11 (24 bytes) 15 17 16 18 // Response codes to master (ESP32 -> Calendar) 17 - #define RSP_PONG 0x80 // Pong response 18 - #define RSP_SET_LED 0x82 // Set LED: month, day, state (legacy) 19 - #define RSP_CLEAR 0x83 // Clear all LEDs (legacy) 20 - #define RSP_STATE_PART1 0x84 // State bitmap months 0-5 (24 bytes) 21 - #define RSP_STATE_PART2 0x85 // State bitmap months 6-11 (24 bytes) 22 - #define RSP_NONE 0x00 // No pending response 19 + #define RSP_PONG 0x80 // Pong response 20 + #define RSP_SET_LED 0x82 // Set LED: month, day, state (legacy) 21 + #define RSP_CLEAR 0x83 // Clear all LEDs (legacy) 22 + #define RSP_STATE_PART1 0x84 // State bitmap months 0-5 (24 bytes) 23 + #define RSP_STATE_PART2 0x85 // State bitmap months 6-11 (24 bytes) 24 + #define RSP_REQUEST_CAL_STATE 0x86 // ESP32 requests calendar's current state 25 + #define RSP_NONE 0x00 // No pending response 23 26 24 27 struct CalendarButton { 25 28 uint8_t month; ··· 48 51 // Get current state (for syncing to goals.garden) 49 52 bool getState(uint8_t month, uint8_t day); 50 53 54 + // Request calendar's current state (for first boot / calendar-wins mode) 55 + void requestCalendarState(); 56 + bool hasCalendarState(); 57 + void getCalendarState(uint32_t* monthStates); 58 + void clearCalendarStateRequest(); 59 + 51 60 // Static callbacks for Wire library 52 61 static void onReceiveStatic(int numBytes); 53 62 static void onRequestStatic(); ··· 60 69 // Internal state - ESP32 is source of truth 61 70 uint32_t _state[12] = {0}; 62 71 bool _ready = false; // True after first sync with goals.garden 72 + 73 + // Calendar state (received from calendar on request) 74 + uint32_t _calState[12] = {0}; 75 + bool _calStateRequested = false; 76 + bool _calStatePart1Received = false; 77 + bool _calStatePart2Received = false; 63 78 64 79 std::queue<CalendarButton> buttonQueue; 65 80
+6
firmware/esp32/GoalsGardenSync/config.h
··· 11 11 #error "config.local.h not found. Copy config.local.h.example to config.local.h and fill in your credentials." 12 12 #endif 13 13 14 + // Default for OFFLINE_SYNC_MODE if not set in config.local.h 15 + // 0 = Block buttons when not synced, 1 = Calendar wins, 2 = goals.garden wins 16 + #ifndef OFFLINE_SYNC_MODE 17 + #define OFFLINE_SYNC_MODE 1 18 + #endif 19 + 14 20 // I2C pins for communication with calendar 15 21 // QT Py ESP32-S3: STEMMA QT connector to Calendar's J2 header (SDA/SCL) 16 22 #define CALENDAR_I2C_SDA 41 // ESP32 STEMMA QT SDA (GPIO41) -> Calendar J2 SDA
+6
firmware/esp32/GoalsGardenSync/config.local.h.example
··· 26 26 // "CET-1CEST,M3.5.0,M10.5.0/3" - Central Europe 27 27 #define TIMEZONE "GMT0BST,M3.5.0/1,M10.5.0" 28 28 29 + // Offline sync mode - what happens when calendar reconnects after being offline 30 + // 0 = Block hardware button presses when not synced to goals.garden 31 + // 1 = Calendar wins - local changes override goals.garden on reconnect (default) 32 + // 2 = goals.garden wins - remote state overwrites local changes on reconnect 33 + #define OFFLINE_SYNC_MODE 1 34 + 29 35 #endif
+40
firmware/libraries/EverydayCalendar/EverydayCalendar_i2c_sync.cpp
··· 154 154 break; 155 155 } 156 156 157 + case RSP_REQUEST_CAL_STATE: { 158 + // ESP32 is requesting our current LED state 159 + sendCalendarState(); 160 + break; 161 + } 162 + 157 163 default: 158 164 break; 159 165 } ··· 200 206 bool EverydayCalendar_i2c_sync::isConnected() { 201 207 return _connected; 202 208 } 209 + 210 + void EverydayCalendar_i2c_sync::sendCalendarState() { 211 + // Send current LED state to ESP32 in two parts (24 bytes each) 212 + Serial.println(F("I2C Sync: sending calendar state to ESP32")); 213 + 214 + // Part 1: months 0-5 215 + Wire.beginTransmission(ESP32_I2C_ADDR); 216 + Wire.write(CMD_CAL_STATE_PART1); 217 + for (uint8_t month = 0; month < 6; month++) { 218 + uint32_t state = _lights->getLedState(month); 219 + Wire.write((uint8_t)(state & 0xFF)); 220 + Wire.write((uint8_t)((state >> 8) & 0xFF)); 221 + Wire.write((uint8_t)((state >> 16) & 0xFF)); 222 + Wire.write((uint8_t)((state >> 24) & 0xFF)); 223 + } 224 + Wire.endTransmission(); 225 + 226 + delay(2); 227 + 228 + // Part 2: months 6-11 229 + Wire.beginTransmission(ESP32_I2C_ADDR); 230 + Wire.write(CMD_CAL_STATE_PART2); 231 + for (uint8_t month = 6; month < 12; month++) { 232 + uint32_t state = _lights->getLedState(month); 233 + Wire.write((uint8_t)(state & 0xFF)); 234 + Wire.write((uint8_t)((state >> 8) & 0xFF)); 235 + Wire.write((uint8_t)((state >> 16) & 0xFF)); 236 + Wire.write((uint8_t)((state >> 24) & 0xFF)); 237 + } 238 + Wire.endTransmission(); 239 + 240 + _lastResponseTime = millis(); 241 + _connected = true; 242 + }
+15 -9
firmware/libraries/EverydayCalendar/EverydayCalendar_i2c_sync.h
··· 9 9 #define ESP32_I2C_ADDR 0x42 10 10 11 11 // Command codes to ESP32 (Calendar -> ESP32) 12 - #define CMD_BUTTON_PRESS 0x01 // Button press: month, day, state 13 - #define CMD_REQUEST_STATE 0x02 // Request full state 14 - #define CMD_PING 0x03 // Ping 12 + #define CMD_BUTTON_PRESS 0x01 // Button press: month, day, state 13 + #define CMD_REQUEST_STATE 0x02 // Request full state from ESP32 14 + #define CMD_PING 0x03 // Ping 15 + #define CMD_CAL_STATE_PART1 0x04 // Calendar sends its state months 0-5 (24 bytes) 16 + #define CMD_CAL_STATE_PART2 0x05 // Calendar sends its state months 6-11 (24 bytes) 15 17 16 18 // Response codes from ESP32 (ESP32 -> Calendar) 17 - #define RSP_PONG 0x80 // Pong response 18 - #define RSP_SET_LED 0x82 // Set LED: month, day, state (legacy) 19 - #define RSP_CLEAR 0x83 // Clear all LEDs (legacy) 20 - #define RSP_STATE_PART1 0x84 // State bitmap months 0-5 (24 bytes) 21 - #define RSP_STATE_PART2 0x85 // State bitmap months 6-11 (24 bytes) 22 - #define RSP_NONE 0x00 // No pending response 19 + #define RSP_PONG 0x80 // Pong response 20 + #define RSP_SET_LED 0x82 // Set LED: month, day, state (legacy) 21 + #define RSP_CLEAR 0x83 // Clear all LEDs (legacy) 22 + #define RSP_STATE_PART1 0x84 // State bitmap months 0-5 (24 bytes) 23 + #define RSP_STATE_PART2 0x85 // State bitmap months 6-11 (24 bytes) 24 + #define RSP_REQUEST_CAL_STATE 0x86 // ESP32 requests calendar's current state 25 + #define RSP_NONE 0x00 // No pending response 23 26 24 27 class EverydayCalendar_i2c_sync { 25 28 public: ··· 47 50 48 51 // Process a response from ESP32 49 52 void processResponse(uint8_t* data, uint8_t len); 53 + 54 + // Send calendar's current state to ESP32 55 + void sendCalendarState(); 50 56 }; 51 57 52 58 #endif