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.

Optimize I2C sync and fix LED flashing

- Replace individual SET_LED commands with bitmap protocol (2 parts)
to reduce I2C transactions from ~367 to 2, eliminating LED flicker
- Sync only on Jetstream (re)connect instead of every 60 seconds
- Save state to EEPROM after receiving from ESP32 for offline boot
- Add debug logging for Jetstream event processing

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

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

+105 -62
+29 -17
firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino
··· 52 52 // Connection state 53 53 bool wifiConnected = false; 54 54 bool atprotoConnected = false; 55 - unsigned long lastSyncTime = 0; 56 - const unsigned long SYNC_INTERVAL_MS = 60000; // Full sync every minute 55 + bool wasJetstreamConnected = false; // Track Jetstream state for (re)connect detection 57 56 58 57 void setup() { 59 58 Serial.begin(115200); ··· 108 107 if (atprotoConnected) { 109 108 jetstream.update(); 110 109 110 + // Sync when Jetstream (re)connects to get current state / catch up on missed events 111 + bool jetstreamConnected = jetstream.isConnected(); 112 + if (jetstreamConnected && !wasJetstreamConnected) { 113 + Serial.println(F("Jetstream (re)connected - syncing")); 114 + performFullSync(); 115 + } 116 + wasJetstreamConnected = jetstreamConnected; 117 + 111 118 // Process any completion changes from Jetstream 112 119 while (jetstream.hasEvent()) { 113 120 JetstreamEvent event = jetstream.getEvent(); ··· 115 122 } 116 123 } 117 124 118 - // Periodic full sync from goals.garden to update internal state 119 - if (atprotoConnected && (millis() - lastSyncTime > SYNC_INTERVAL_MS)) { 120 - performFullSync(); 121 - } 122 - 123 125 delay(100); // Delay to reduce CPU usage and heat 124 126 } 125 127 ··· 242 244 Serial.print(F("DID: ")); 243 245 Serial.println(atproto.getDid()); 244 246 245 - // Start Jetstream subscription 247 + // Start Jetstream subscription - sync will happen when it connects 246 248 jetstream.begin(atproto.getDid()); 247 249 248 - // Wait for calendar to finish its startup animation before syncing 250 + // Wait for calendar to finish its startup animation 249 251 // Calendar takes ~5s for honey drip + fade + delays 250 252 Serial.println(F("Waiting for calendar to be ready...")); 251 253 delay(6000); 252 - 253 - performFullSync(); 254 254 } else { 255 255 Serial.println(F("ATProto connection failed")); 256 256 Serial.println(F("Check BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD in config.local.h")); ··· 333 333 334 334 if (event.action == JetstreamAction::Create) { 335 335 // For creates, check if it's for our goal 336 - if (event.goalUri != goalUri) return; 336 + Serial.printf("Jetstream create: goalUri='%s' year=%d month=%d day=%d\n", 337 + event.goalUri.c_str(), event.year, event.month, event.day); 338 + Serial.printf("Expected goalUri='%s'\n", goalUri.c_str()); 339 + 340 + if (event.goalUri != goalUri) { 341 + Serial.println(F("Goal URI mismatch - ignoring")); 342 + return; 343 + } 337 344 338 345 year = event.year; 339 346 month = event.month; ··· 350 357 } 351 358 352 359 // Only process if it's for current year 353 - if (year != currentYear) return; 360 + if (year != currentYear) { 361 + Serial.printf("Year mismatch: %d != %d - ignoring\n", year, currentYear); 362 + return; 363 + } 354 364 355 365 // Convert to calendar coordinates (0-indexed) 356 366 uint8_t calMonth = month - 1; // 0-11 357 367 uint8_t calDay = day - 1; // 0-30 358 368 359 - if (calMonth >= 12 || calDay >= 31) return; 369 + if (calMonth >= 12 || calDay >= 31) { 370 + Serial.printf("Invalid date: month=%d day=%d - ignoring\n", calMonth, calDay); 371 + return; 372 + } 360 373 361 374 // Update internal state (calendar will pick up on next poll) 362 375 if (event.action == JetstreamAction::Create) { 376 + Serial.printf("Setting LED: month=%d day=%d ON\n", calMonth, calDay); 363 377 calendarI2C.setState(calMonth, calDay, true); 364 378 } else if (event.action == JetstreamAction::Delete) { 379 + Serial.printf("Setting LED: month=%d day=%d OFF\n", calMonth, calDay); 365 380 calendarI2C.setState(calMonth, calDay, false); 366 381 } 367 382 } ··· 397 412 398 413 // Store completion rkeys for later deletion lookups 399 414 atproto.cacheCompletions(completions); 400 - 401 - // Update sync timestamp to prevent immediate re-sync 402 - lastSyncTime = millis(); 403 415 404 416 // Mark ESP32 as ready - calendar can now receive valid state 405 417 calendarI2C.setReady(true);
+17 -29
firmware/esp32/GoalsGardenSync/calendar_i2c.cpp
··· 69 69 } 70 70 71 71 case CMD_PING: { 72 - PendingCmd cmd; 73 - cmd.type = RSP_PONG; 74 - cmd.data[0] = RSP_PONG; 75 - cmd.len = 1; 76 - pendingCmds.push(cmd); 72 + PendingCmd pong; 73 + pong.data[0] = RSP_PONG; 74 + pong.len = 1; 75 + pendingCmds.push(pong); 77 76 break; 78 77 } 79 78 ··· 98 97 } 99 98 } 100 99 101 - // Queue CLEAR + SET_LED commands for current state 100 + // Queue state bitmap (2 parts to fit in Wire's 32-byte buffer) 102 101 void CalendarI2C::queueCurrentState() { 103 102 // Clear the queue first 104 103 while (!pendingCmds.empty()) { 105 104 pendingCmds.pop(); 106 105 } 107 106 108 - // Queue CLEAR command 109 - PendingCmd clearCmd; 110 - clearCmd.type = RSP_CLEAR; 111 - clearCmd.data[0] = RSP_CLEAR; 112 - clearCmd.len = 1; 113 - pendingCmds.push(clearCmd); 107 + // Part 1: months 0-5 (1 type byte + 24 data bytes = 25 bytes) 108 + PendingCmd part1; 109 + part1.data[0] = RSP_STATE_PART1; 110 + memcpy(&part1.data[1], &_state[0], 24); // 6 months × 4 bytes 111 + part1.len = 25; 112 + pendingCmds.push(part1); 114 113 115 - // Queue SET_LED for each lit day 116 - int count = 0; 117 - for (uint8_t month = 0; month < 12; month++) { 118 - for (uint8_t day = 0; day < 31; day++) { 119 - if (_state[month] & ((uint32_t)1 << day)) { 120 - PendingCmd cmd; 121 - cmd.type = RSP_SET_LED; 122 - cmd.data[0] = RSP_SET_LED; 123 - cmd.data[1] = month; 124 - cmd.data[2] = day; 125 - cmd.data[3] = 1; 126 - cmd.len = 4; 127 - pendingCmds.push(cmd); 128 - count++; 129 - } 130 - } 131 - } 114 + // Part 2: months 6-11 (1 type byte + 24 data bytes = 25 bytes) 115 + PendingCmd part2; 116 + part2.data[0] = RSP_STATE_PART2; 117 + memcpy(&part2.data[1], &_state[6], 24); // 6 months × 4 bytes 118 + part2.len = 25; 119 + pendingCmds.push(part2); 132 120 } 133 121 134 122 bool CalendarI2C::hasButtonPress() {
+5 -4
firmware/esp32/GoalsGardenSync/calendar_i2c.h
··· 15 15 16 16 // Response codes to master (ESP32 -> Calendar) 17 17 #define RSP_PONG 0x80 // Pong response 18 - #define RSP_SET_LED 0x82 // Set LED: month, day, state 19 - #define RSP_CLEAR 0x83 // Clear all LEDs 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) 20 22 #define RSP_NONE 0x00 // No pending response 21 23 22 24 struct CalendarButton { ··· 63 65 64 66 // Pending commands to send to calendar 65 67 struct PendingCmd { 66 - uint8_t type; 67 - uint8_t data[4]; // Max: type + month + day + state 68 + uint8_t data[25]; // Max: 1 type + 24 bytes bitmap 68 69 uint8_t len; 69 70 }; 70 71 std::queue<PendingCmd> pendingCmds;
+50 -10
firmware/libraries/EverydayCalendar/EverydayCalendar_i2c_sync.cpp
··· 55 55 return; 56 56 } 57 57 58 - // Poll for responses until we get RSP_NONE (no more data) 59 - // ESP32 will send CLEAR followed by SET_LED commands (up to 366 for full year) 60 - uint8_t responseCount = 0; 61 - for (int i = 0; i < 400; i++) { // CLEAR + up to 366 SET_LEDs + margin 62 - delay(10); // Small delay between polls 58 + // Give ESP32 a moment to queue responses 59 + delay(2); 60 + 61 + // Read state bitmap (2 parts: months 0-5 and months 6-11) 62 + // Each part is 25 bytes: 1 type + 24 data (6 months × 4 bytes) 63 + bool receivedPart1 = false; 64 + bool receivedPart2 = false; 63 65 64 - uint8_t bytesReceived = Wire.requestFrom((uint8_t)ESP32_I2C_ADDR, (uint8_t)8); 66 + for (int i = 0; i < 3; i++) { // 2 parts + 1 for RSP_NONE 67 + uint8_t bytesReceived = Wire.requestFrom((uint8_t)ESP32_I2C_ADDR, (uint8_t)25); 65 68 66 69 if (bytesReceived > 0) { 67 - uint8_t buffer[8]; 70 + uint8_t buffer[25]; 68 71 uint8_t idx = 0; 69 72 70 - while (Wire.available() && idx < 8) { 73 + while (Wire.available() && idx < 25) { 71 74 buffer[idx++] = Wire.read(); 72 75 } 73 76 74 77 if (idx > 0 && buffer[0] != RSP_NONE) { 78 + if (buffer[0] == RSP_STATE_PART1) { 79 + receivedPart1 = true; 80 + } else if (buffer[0] == RSP_STATE_PART2) { 81 + receivedPart2 = true; 82 + } 75 83 processResponse(buffer, idx); 76 84 _lastResponseTime = millis(); 77 85 _connected = true; 78 - responseCount++; 79 86 } else { 80 87 // No more data 81 88 break; ··· 83 90 } else { 84 91 break; 85 92 } 93 + } 94 + 95 + // If we received a full state update, save to EEPROM 96 + if (receivedPart1 && receivedPart2) { 97 + _lights->saveLedStatesToMemory(); 86 98 } 87 99 } 88 100 ··· 96 108 // Ping response - nothing to do 97 109 break; 98 110 111 + case RSP_STATE_PART1: { 112 + // Months 0-5 bitmap (24 bytes = 6 months × 4 bytes) 113 + if (len >= 25) { 114 + uint32_t* monthData = (uint32_t*)&data[1]; 115 + for (uint8_t month = 0; month < 6; month++) { 116 + uint32_t state = monthData[month]; 117 + for (uint8_t day = 0; day < 31; day++) { 118 + _lights->setLED(month, day, (state & ((uint32_t)1 << day)) != 0); 119 + } 120 + } 121 + } 122 + break; 123 + } 124 + 125 + case RSP_STATE_PART2: { 126 + // Months 6-11 bitmap (24 bytes = 6 months × 4 bytes) 127 + if (len >= 25) { 128 + uint32_t* monthData = (uint32_t*)&data[1]; 129 + for (uint8_t month = 0; month < 6; month++) { 130 + uint32_t state = monthData[month]; 131 + for (uint8_t day = 0; day < 31; day++) { 132 + _lights->setLED(month + 6, day, (state & ((uint32_t)1 << day)) != 0); 133 + } 134 + } 135 + } 136 + break; 137 + } 138 + 139 + // Legacy support (not used by new ESP32 code) 99 140 case RSP_SET_LED: { 100 141 if (len >= 4) { 101 142 uint8_t month = data[1]; 102 143 uint8_t day = data[2]; 103 144 bool on = data[3] != 0; 104 - 105 145 if (month < 12 && day < 31) { 106 146 _lights->setLED(month, day, on); 107 147 }
+4 -2
firmware/libraries/EverydayCalendar/EverydayCalendar_i2c_sync.h
··· 15 15 16 16 // Response codes from ESP32 (ESP32 -> Calendar) 17 17 #define RSP_PONG 0x80 // Pong response 18 - #define RSP_SET_LED 0x82 // Set LED: month, day, state 19 - #define RSP_CLEAR 0x83 // Clear all LEDs 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) 20 22 #define RSP_NONE 0x00 // No pending response 21 23 22 24 class EverydayCalendar_i2c_sync {