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.

Fix critical bugs and clean up debug logging

Bar-raiser review fixes:
- Fix race condition: set g_calendarI2C before Wire.begin()
- Fix Jetstream delete events: look up completion by rkey in cache
- Increase sync limit from 50 to 400 responses (supports full year)
- Add findCompletionByRkey() for reverse rkey lookups
- Add bounds validation for month/day in Jetstream handler

Code cleanup:
- Remove verbose debug logging that caused LED flashing
- Remove dead code: EverydayCalendar_sync.cpp/h (unused serial implementation)
- Remove loop counter debug output from calendar sketch

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

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

+47 -339
+25 -10
firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino
··· 323 323 void handleJetstreamEvent(JetstreamEvent& event) { 324 324 if (event.collection != "garden.goals.completion") return; 325 325 326 - // Check if this completion is for our goal 327 - if (event.goalUri != goalUri) return; 328 - 329 - Serial.printf("Jetstream event: %s %s\n", 330 - event.action == JetstreamAction::Create ? "create" : "delete", 331 - event.rkey.c_str()); 332 - 333 326 // Get current year 334 327 time_t now = time(nullptr); 335 328 struct tm timeinfo; 336 329 localtime_r(&now, &timeinfo); 337 330 int currentYear = timeinfo.tm_year + 1900; 338 331 332 + int year, month, day; 333 + 334 + if (event.action == JetstreamAction::Create) { 335 + // For creates, check if it's for our goal 336 + if (event.goalUri != goalUri) return; 337 + 338 + year = event.year; 339 + month = event.month; 340 + day = event.day; 341 + 342 + } else if (event.action == JetstreamAction::Delete) { 343 + // For deletes, look up the completion by rkey in our cache 344 + if (!atproto.findCompletionByRkey(event.rkey, &year, &month, &day)) { 345 + // Not in our cache, so probably not for our goal 346 + return; 347 + } 348 + } else { 349 + return; 350 + } 351 + 339 352 // Only process if it's for current year 340 - if (event.year != currentYear) return; 353 + if (year != currentYear) return; 341 354 342 355 // Convert to calendar coordinates (0-indexed) 343 - uint8_t calMonth = event.month - 1; // 0-11 344 - uint8_t calDay = event.day - 1; // 0-30 356 + uint8_t calMonth = month - 1; // 0-11 357 + uint8_t calDay = day - 1; // 0-30 358 + 359 + if (calMonth >= 12 || calDay >= 31) return; 345 360 346 361 // Update internal state (calendar will pick up on next poll) 347 362 if (event.action == JetstreamAction::Create) {
+12
firmware/esp32/GoalsGardenSync/atproto_client.cpp
··· 410 410 } 411 411 return ""; 412 412 } 413 + 414 + bool ATProtoClient::findCompletionByRkey(const String& rkey, int* year, int* month, int* day) { 415 + for (const auto& comp : completionCache) { 416 + if (comp.rkey == rkey) { 417 + *year = comp.year; 418 + *month = comp.month; 419 + *day = comp.day; 420 + return true; 421 + } 422 + } 423 + return false; 424 + }
+1
firmware/esp32/GoalsGardenSync/atproto_client.h
··· 40 40 // Cache for completion lookups 41 41 void cacheCompletions(const std::vector<Completion>& completions); 42 42 String findCompletionRkey(int year, int month, int day); 43 + bool findCompletionByRkey(const String& rkey, int* year, int* month, int* day); 43 44 44 45 private: 45 46 String pdsUrl;
+6 -27
firmware/esp32/GoalsGardenSync/calendar_i2c.cpp
··· 5 5 CalendarI2C* g_calendarI2C = nullptr; 6 6 7 7 void CalendarI2C::begin(int sdaPin, int sclPin) { 8 + // Set global pointer BEFORE Wire.begin() to avoid race condition 9 + // (callbacks could fire immediately after Wire.begin()) 8 10 g_calendarI2C = this; 9 - 10 - Serial.printf("Calendar I2C: SDA=GPIO%d, SCL=GPIO%d, addr=0x%02X\n", 11 - sdaPin, sclPin, CALENDAR_I2C_ADDR); 12 11 13 12 Wire.begin(CALENDAR_I2C_ADDR, sdaPin, sclPin, 100000); // 100kHz 14 13 Wire.onReceive(onReceiveStatic); 15 14 Wire.onRequest(onRequestStatic); 16 15 17 - Serial.println(F("Calendar I2C slave initialized")); 16 + Serial.printf("I2C slave: addr=0x%02X, SDA=%d, SCL=%d\n", 17 + CALENDAR_I2C_ADDR, sdaPin, sclPin); 18 18 } 19 19 20 20 void CalendarI2C::update() { 21 - // I2C events are handled by callbacks 22 - // Debug: show queue status periodically 23 - static unsigned long lastDebug = 0; 24 - if (millis() - lastDebug > 10000) { 25 - lastDebug = millis(); 26 - int litCount = 0; 27 - for (int m = 0; m < 12; m++) { 28 - for (int d = 0; d < 31; d++) { 29 - if (_state[m] & ((uint32_t)1 << d)) litCount++; 30 - } 31 - } 32 - Serial.printf("I2C: %d days lit, %d cmds queued\n", litCount, pendingCmds.size()); 33 - } 21 + // I2C events are handled by callbacks - nothing to do here 34 22 } 35 23 36 24 // Static callback wrapper ··· 74 62 75 63 case CMD_REQUEST_STATE: { 76 64 if (_ready) { 77 - Serial.println(F("I2C: State requested - queueing current state")); 78 65 queueCurrentState(); 79 - } else { 80 - Serial.println(F("I2C: State requested but not ready yet")); 81 - // Don't queue anything - calendar will retry 82 66 } 67 + // If not ready, don't queue anything - calendar will retry 83 68 break; 84 69 } 85 70 86 71 case CMD_PING: { 87 - Serial.println(F("I2C: Ping received")); 88 72 PendingCmd cmd; 89 73 cmd.type = RSP_PONG; 90 74 cmd.data[0] = RSP_PONG; ··· 94 78 } 95 79 96 80 default: 97 - Serial.printf("I2C: Unknown command 0x%02X\n", cmd); 98 81 break; 99 82 } 100 83 ··· 146 129 } 147 130 } 148 131 } 149 - Serial.printf("I2C: Queued CLEAR + %d SET_LEDs\n", count); 150 132 } 151 133 152 134 bool CalendarI2C::hasButtonPress() { ··· 184 166 185 167 void CalendarI2C::setReady(bool ready) { 186 168 _ready = ready; 187 - if (ready) { 188 - Serial.println(F("I2C: ESP32 marked as ready")); 189 - } 190 169 } 191 170 192 171 bool CalendarI2C::isReady() {
+3 -17
firmware/libraries/EverydayCalendar/EverydayCalendar_i2c_sync.cpp
··· 45 45 } 46 46 47 47 void EverydayCalendar_i2c_sync::requestState() { 48 - Serial.println(F("I2C Sync: polling ESP32...")); 49 - 50 48 // Send state request to ESP32 51 49 Wire.beginTransmission(ESP32_I2C_ADDR); 52 50 Wire.write(CMD_REQUEST_STATE); 53 - Serial.println(F("I2C Sync: sending...")); 54 51 uint8_t error = Wire.endTransmission(); 55 - Serial.print(F("I2C Sync: endTransmission returned ")); 56 - Serial.println(error); 57 52 58 53 if (error != 0) { 59 - Serial.print(F("I2C Sync: no response (error ")); 60 - Serial.print(error); 61 - Serial.println(F(")")); 54 + // ESP32 not responding - will retry on next poll 62 55 return; 63 56 } 64 57 65 58 // Poll for responses until we get RSP_NONE (no more data) 66 - // ESP32 will send CLEAR followed by SET_LED commands 67 - Serial.println(F("I2C Sync: reading responses...")); 59 + // ESP32 will send CLEAR followed by SET_LED commands (up to 366 for full year) 68 60 uint8_t responseCount = 0; 69 - for (int i = 0; i < 50; i++) { // Max 50 responses (CLEAR + up to 49 SET_LEDs) 61 + for (int i = 0; i < 400; i++) { // CLEAR + up to 366 SET_LEDs + margin 70 62 delay(10); // Small delay between polls 71 63 72 64 uint8_t bytesReceived = Wire.requestFrom((uint8_t)ESP32_I2C_ADDR, (uint8_t)8); ··· 91 83 } else { 92 84 break; 93 85 } 94 - } 95 - 96 - if (responseCount > 0) { 97 - Serial.print(F("I2C Sync: got ")); 98 - Serial.print(responseCount); 99 - Serial.println(F(" responses")); 100 86 } 101 87 } 102 88
-205
firmware/libraries/EverydayCalendar/EverydayCalendar_sync.cpp
··· 1 - #include "EverydayCalendar_sync.h" 2 - #include "EverydayCalendar_lights.h" 3 - #include <Arduino.h> 4 - 5 - #define SYNC_BAUD_RATE 9600 // NeoSWSerial supports 9600, 19200, 31250, 38400 6 - #define COMMAND_TIMEOUT_MS 30000 7 - 8 - void EverydayCalendar_sync::configure(uint8_t rxPin, uint8_t txPin, EverydayCalendar_lights* lights) { 9 - _serial = new NeoSWSerial(rxPin, txPin); 10 - _lights = lights; 11 - _cmdIndex = 0; 12 - _lastCommandTime = 0; 13 - } 14 - 15 - void EverydayCalendar_sync::begin() { 16 - _serial->begin(SYNC_BAUD_RATE); 17 - Serial.println(F("Sync: initialized (NeoSWSerial 9600)")); 18 - 19 - // Send a test message to ESP32 on startup 20 - delay(100); 21 - _serial->println(F("HELLO_FROM_CALENDAR")); 22 - Serial.println(F("Sync: sent HELLO to ESP32")); 23 - } 24 - 25 - void EverydayCalendar_sync::update() { 26 - static unsigned long lastDebug = 0; 27 - static uint16_t byteCount = 0; 28 - 29 - // Process incoming serial data 30 - while (_serial->available()) { 31 - char c = _serial->read(); 32 - byteCount++; 33 - 34 - // Debug: print each received byte 35 - Serial.print(F("RX: 0x")); 36 - Serial.print((uint8_t)c, HEX); 37 - Serial.print(F(" '")); 38 - Serial.print(c >= 32 && c < 127 ? c : '?'); 39 - Serial.println(F("'")); 40 - 41 - if (c == '\n' || c == '\r') { 42 - if (_cmdIndex > 0) { 43 - _cmdBuffer[_cmdIndex] = '\0'; 44 - processCommand(); 45 - _cmdIndex = 0; 46 - } 47 - } else if (_cmdIndex < sizeof(_cmdBuffer) - 1) { 48 - _cmdBuffer[_cmdIndex++] = c; 49 - } 50 - } 51 - 52 - // Periodic debug every 5 seconds 53 - if (millis() - lastDebug > 5000) { 54 - Serial.print(F("Sync: bytes=")); 55 - Serial.println(byteCount); 56 - 57 - // Send a ping to ESP32 58 - _serial->println(F("CAL_PING")); 59 - 60 - lastDebug = millis(); 61 - } 62 - } 63 - 64 - void EverydayCalendar_sync::processCommand() { 65 - _lastCommandTime = millis(); 66 - 67 - Serial.print(F("Sync cmd: ")); 68 - Serial.println(_cmdBuffer); 69 - 70 - // Parse command 71 - if (strncmp(_cmdBuffer, "SET,", 4) == 0) { 72 - handleSet(_cmdBuffer + 4); 73 - } else if (strcmp(_cmdBuffer, "SYNC") == 0) { 74 - handleSync(); 75 - } else if (strcmp(_cmdBuffer, "CLEAR") == 0) { 76 - handleClear(); 77 - } else if (strcmp(_cmdBuffer, "PING") == 0) { 78 - _serial->println(F("PONG")); 79 - } else { 80 - _serial->print(F("ERR,Unknown command: ")); 81 - _serial->println(_cmdBuffer); 82 - } 83 - } 84 - 85 - void EverydayCalendar_sync::handleSet(const char* args) { 86 - // Parse: month,day,on 87 - int month, day, on; 88 - if (sscanf(args, "%d,%d,%d", &month, &day, &on) == 3) { 89 - if (month >= 0 && month < 12 && day >= 0 && day < 31) { 90 - _lights->setLED(month, day, on != 0); 91 - _lights->saveLedStatesToMemory(); 92 - _serial->println(F("OK")); 93 - 94 - Serial.print(F("Sync: SET ")); 95 - Serial.print(month); 96 - Serial.print(F(",")); 97 - Serial.print(day); 98 - Serial.print(F(" = ")); 99 - Serial.println(on); 100 - } else { 101 - _serial->println(F("ERR,Invalid month/day")); 102 - } 103 - } else { 104 - _serial->println(F("ERR,Parse error")); 105 - } 106 - } 107 - 108 - void EverydayCalendar_sync::handleSync() { 109 - Serial.println(F("Sync: full state requested")); 110 - sendState(); 111 - } 112 - 113 - void EverydayCalendar_sync::handleClear() { 114 - _lights->clearAllLEDs(); 115 - _lights->saveLedStatesToMemory(); 116 - _serial->println(F("OK")); 117 - Serial.println(F("Sync: cleared all LEDs")); 118 - } 119 - 120 - void EverydayCalendar_sync::sendState() { 121 - _serial->print(F("STATE")); 122 - for (uint8_t month = 0; month < 12; month++) { 123 - _serial->print(','); 124 - _serial->print(_lights->getLedState(month), HEX); 125 - } 126 - _serial->println(); 127 - } 128 - 129 - void EverydayCalendar_sync::sendButtonPress(uint8_t month, uint8_t day, bool newState) { 130 - _serial->print(F("BTN,")); 131 - _serial->print(month); 132 - _serial->print(','); 133 - _serial->print(day); 134 - _serial->print(','); 135 - _serial->println(newState ? 1 : 0); 136 - 137 - Serial.print(F("Sync: sent BTN ")); 138 - Serial.print(month); 139 - Serial.print(','); 140 - Serial.print(day); 141 - Serial.print(','); 142 - Serial.println(newState); 143 - } 144 - 145 - bool EverydayCalendar_sync::isConnected() { 146 - if (_lastCommandTime == 0) return false; 147 - return (millis() - _lastCommandTime) < COMMAND_TIMEOUT_MS; 148 - } 149 - 150 - // Blocking sync for startup - call BEFORE Timer2 starts! 151 - // This works because there's no interrupt conflict yet. 152 - bool EverydayCalendar_sync::waitForInitialSync(unsigned long timeoutMs) { 153 - Serial.println(F("Sync: waiting for initial state...")); 154 - 155 - // Request state from ESP32 156 - _serial->println(F("REQUEST_STATE")); 157 - 158 - unsigned long startTime = millis(); 159 - _cmdIndex = 0; 160 - 161 - while (millis() - startTime < timeoutMs) { 162 - while (_serial->available()) { 163 - char c = _serial->read(); 164 - 165 - Serial.print(F("InitRX: 0x")); 166 - Serial.print((uint8_t)c, HEX); 167 - Serial.print(F(" '")); 168 - Serial.print(c >= 32 && c < 127 ? c : '?'); 169 - Serial.println(F("'")); 170 - 171 - if (c == '\n' || c == '\r') { 172 - if (_cmdIndex > 0) { 173 - _cmdBuffer[_cmdIndex] = '\0'; 174 - 175 - // Check for STATE response 176 - if (strncmp(_cmdBuffer, "STATE,", 6) == 0) { 177 - Serial.println(F("Sync: received initial state")); 178 - 179 - // Parse STATE,<m0>,<m1>,...<m11> 180 - char* ptr = _cmdBuffer + 6; 181 - for (uint8_t month = 0; month < 12 && *ptr; month++) { 182 - uint32_t value = strtoul(ptr, &ptr, 16); 183 - // Set each day from the bitmask 184 - for (uint8_t day = 0; day < 31; day++) { 185 - _lights->setLED(month, day, (value & ((uint32_t)1 << day)) != 0); 186 - } 187 - if (*ptr == ',') ptr++; 188 - } 189 - 190 - _lights->saveLedStatesToMemory(); 191 - Serial.println(F("Sync: initial state applied")); 192 - return true; 193 - } 194 - 195 - _cmdIndex = 0; 196 - } 197 - } else if (_cmdIndex < sizeof(_cmdBuffer) - 1) { 198 - _cmdBuffer[_cmdIndex++] = c; 199 - } 200 - } 201 - } 202 - 203 - Serial.println(F("Sync: timeout waiting for initial state")); 204 - return false; 205 - }
-62
firmware/libraries/EverydayCalendar/EverydayCalendar_sync.h
··· 1 - #ifndef __EVERYDAYCALENDAR_SYNC_H 2 - #define __EVERYDAYCALENDAR_SYNC_H 3 - 4 - #include <stdint.h> 5 - #include <NeoSWSerial.h> 6 - 7 - // Forward declarations 8 - class EverydayCalendar_lights; 9 - 10 - // Serial protocol for ESP32 sync (9600 baud) 11 - // Uses NeoSWSerial which is more interrupt-tolerant than SoftwareSerial. 12 - // NeoSWSerial uses Timer0 (millis) for timing, avoiding Timer2 conflicts. 13 - // 14 - // Commands from ESP32 to Arduino: 15 - // SET,<month>,<day>,<on>\n - Set LED state (month 0-11, day 0-30, on 0/1) 16 - // SYNC\n - Request full state dump 17 - // CLEAR\n - Clear all LEDs 18 - // PING\n - Connection check 19 - // 20 - // Commands from Arduino to ESP32: 21 - // BTN,<month>,<day>,<on>\n - Button pressed, LED toggled to state 22 - // STATE,<m0>,<m1>,...<m11>\n - Full state: 12 hex values (uint32), one per month 23 - // OK\n - Acknowledgment 24 - // PONG\n - Response to PING 25 - // ERR,<msg>\n - Error message 26 - 27 - class EverydayCalendar_sync 28 - { 29 - public: 30 - // Initialize with pins (A2=16, A3=17 on ATmega328P) 31 - void configure(uint8_t rxPin, uint8_t txPin, EverydayCalendar_lights* lights); 32 - void begin(); 33 - 34 - // Call in loop() to process incoming commands 35 - void update(); 36 - 37 - // Call when a button is pressed to notify ESP32 38 - void sendButtonPress(uint8_t month, uint8_t day, bool newState); 39 - 40 - // Check if sync is connected (received any valid command recently) 41 - bool isConnected(); 42 - 43 - // Blocking receive for initial sync (call BEFORE Timer2 starts) 44 - // Returns true if state was received from ESP32 45 - bool waitForInitialSync(unsigned long timeoutMs); 46 - 47 - private: 48 - NeoSWSerial* _serial; 49 - EverydayCalendar_lights* _lights; 50 - 51 - char _cmdBuffer[64]; 52 - uint8_t _cmdIndex; 53 - unsigned long _lastCommandTime; 54 - 55 - void processCommand(); 56 - void handleSet(const char* args); 57 - void handleSync(); 58 - void handleClear(); 59 - void sendState(); 60 - }; 61 - 62 - #endif
-18
firmware/sketches/EverydayCalendar/EverydayCalendar.ino
··· 138 138 } 139 139 140 140 void loop() { 141 - static unsigned long loopCount = 0; 142 - loopCount++; 143 - 144 - static unsigned long lastLoopDebug = 0; 145 - if (millis() - lastLoopDebug > 5000) { 146 - lastLoopDebug = millis(); 147 - Serial.print(F("loop() #")); 148 - Serial.print(loopCount); 149 - Serial.print(F(" millis=")); 150 - Serial.println(millis()); 151 - } 152 - 153 141 // Process incoming sync commands from ESP32 154 142 cal_sync.update(); 155 143 ··· 158 146 static const uint8_t debounceCount = 3; 159 147 static const uint16_t clearCalendarCount = 1300; // ~40 seconds. This is in units of touch sampling interval ~= 30ms. 160 148 Point buttonPressed = {(char)0xFF, (char)0xFF}; 161 - 162 - // Debug: print every 100 loops to see if we're running 163 - if (loopCount % 100 == 0) { 164 - Serial.print(F("loop #")); 165 - Serial.println(loopCount); 166 - } 167 149 168 150 bool touch = cal_touch.scanForTouch(); 169 151 // Handle a button press