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 connection status animations and improve sync reliability

- Increase polling frequency from 5s to 1s for faster state updates
- Play ripple animation on both connect and disconnect events
- Add corner LED animation (cycles 4 corners) when ESP32 connected but
no internet, providing visual feedback for connection state
- Fix Jetstream deletion events not updating calendar by maintaining
completion cache on create/delete events
- Add logging for ESP32 connection established, button presses, and
Jetstream events
- Use compact calendar state display (⊙/·) on startup

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

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

+231 -95
+10
firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino
··· 563 563 year = event.year; 564 564 month = event.month; 565 565 day = event.day; 566 + 567 + // Add to cache so we can look it up when deleted 568 + atproto.addCompletionToCache(event.rkey, year, month, day, event.goalUri); 569 + 566 570 } else if (event.action == JetstreamAction::Delete) { 567 571 if (!atproto.findCompletionByRkey(event.rkey, &year, &month, &day)) { 572 + Serial.printf("Jetstream delete: rkey %s not found in cache\n", event.rkey.c_str()); 568 573 return; 569 574 } 575 + // Remove from cache 576 + atproto.removeCompletionFromCache(event.rkey); 577 + 570 578 } else { 571 579 return; 572 580 } ··· 579 587 if (calMonth >= 12 || calDay >= 31) return; 580 588 581 589 if (event.action == JetstreamAction::Create) { 590 + Serial.printf("Jetstream: setting LED month=%d day=%d ON\n", calMonth, calDay + 1); 582 591 calendarI2C.setState(calMonth, calDay, true); 583 592 } else if (event.action == JetstreamAction::Delete) { 593 + Serial.printf("Jetstream: setting LED month=%d day=%d OFF\n", calMonth, calDay + 1); 584 594 calendarI2C.setState(calMonth, calDay, false); 585 595 } 586 596 }
+26
firmware/esp32/GoalsGardenSync/atproto_client.cpp
··· 399 399 completionCache = completions; 400 400 } 401 401 402 + void ATProtoClient::addCompletionToCache(const String& rkey, int year, int month, int day, const String& goalUri) { 403 + // Check if already in cache (avoid duplicates) 404 + for (const auto& comp : completionCache) { 405 + if (comp.rkey == rkey) { 406 + return; 407 + } 408 + } 409 + 410 + Completion comp; 411 + comp.rkey = rkey; 412 + comp.year = year; 413 + comp.month = month; 414 + comp.day = day; 415 + comp.goalUri = goalUri; 416 + completionCache.push_back(comp); 417 + } 418 + 419 + void ATProtoClient::removeCompletionFromCache(const String& rkey) { 420 + for (auto it = completionCache.begin(); it != completionCache.end(); ++it) { 421 + if (it->rkey == rkey) { 422 + completionCache.erase(it); 423 + return; 424 + } 425 + } 426 + } 427 + 402 428 String ATProtoClient::findCompletionRkey(int year, int month, int day) { 403 429 for (const auto& comp : completionCache) { 404 430 if (comp.year == year && comp.month == month && comp.day == day) {
+2
firmware/esp32/GoalsGardenSync/atproto_client.h
··· 42 42 43 43 // Cache for completion lookups 44 44 void cacheCompletions(const std::vector<Completion>& completions); 45 + void addCompletionToCache(const String& rkey, int year, int month, int day, const String& goalUri); 46 + void removeCompletionFromCache(const String& rkey); 45 47 String findCompletionRkey(int year, int month, int day); 46 48 bool findCompletionByRkey(const String& rkey, int* year, int* month, int* day); 47 49
+135 -86
firmware/esp32/GoalsGardenSync/calendar_i2c.cpp
··· 2 2 #include "config.h" 3 3 4 4 // Global instance for static callbacks 5 - CalendarI2C* g_calendarI2C = nullptr; 5 + CalendarI2C *g_calendarI2C = nullptr; 6 6 7 - void CalendarI2C::begin(int sdaPin, int sclPin) { 7 + void CalendarI2C::begin(int sdaPin, int sclPin) 8 + { 8 9 // Set global pointer BEFORE Wire.begin() to avoid race condition 9 10 // (callbacks could fire immediately after Wire.begin()) 10 11 g_calendarI2C = this; 11 12 12 - Wire.begin(CALENDAR_I2C_ADDR, sdaPin, sclPin, 100000); // 100kHz 13 + Wire.begin(CALENDAR_I2C_ADDR, sdaPin, sclPin, 100000); // 100kHz 13 14 Wire.onReceive(onReceiveStatic); 14 15 Wire.onRequest(onRequestStatic); 15 16 16 17 Serial.printf("I2C slave: addr=0x%02X, SDA=%d, SCL=%d\n", 17 - CALENDAR_I2C_ADDR, sdaPin, sclPin); 18 + CALENDAR_I2C_ADDR, sdaPin, sclPin); 18 19 } 19 20 20 - void CalendarI2C::update() { 21 + void CalendarI2C::update() 22 + { 21 23 // I2C events are handled by callbacks - nothing to do here 22 24 } 23 25 24 26 // Static callback wrapper 25 - void CalendarI2C::onReceiveStatic(int numBytes) { 26 - if (g_calendarI2C) { 27 + void CalendarI2C::onReceiveStatic(int numBytes) 28 + { 29 + if (g_calendarI2C) 30 + { 27 31 g_calendarI2C->onReceive(numBytes); 28 32 } 29 33 } 30 34 31 35 // Static callback wrapper 32 - void CalendarI2C::onRequestStatic() { 33 - if (g_calendarI2C) { 36 + void CalendarI2C::onRequestStatic() 37 + { 38 + if (g_calendarI2C) 39 + { 34 40 g_calendarI2C->onRequest(); 35 41 } 36 42 } 37 43 38 44 // Called when master sends data to us 39 - void CalendarI2C::onReceive(int numBytes) { 40 - if (numBytes < 1) return; 45 + void CalendarI2C::onReceive(int numBytes) 46 + { 47 + if (numBytes < 1) 48 + return; 41 49 42 50 uint8_t cmd = Wire.read(); 43 51 numBytes--; 44 52 45 - switch (cmd) { 46 - case CMD_BUTTON_PRESS: { 47 - if (numBytes >= 3) { 48 - CalendarButton btn; 49 - btn.month = Wire.read(); 50 - btn.day = Wire.read(); 51 - btn.state = Wire.read() != 0; 52 - buttonQueue.push(btn); 53 + switch (cmd) 54 + { 55 + case CMD_BUTTON_PRESS: 56 + { 57 + if (numBytes >= 3) 58 + { 59 + CalendarButton btn; 60 + btn.month = Wire.read(); 61 + btn.day = Wire.read(); 62 + btn.state = Wire.read() != 0; 63 + buttonQueue.push(btn); 53 64 54 - // Update internal state immediately 55 - setState(btn.month, btn.day, btn.state); 56 - } 57 - break; 65 + // Update internal state immediately 66 + setState(btn.month, btn.day, btn.state); 67 + Serial.printf("I2C recv: button press month=%d day=%d state=%d\n", 68 + btn.month, btn.day + 1, btn.state); 58 69 } 70 + break; 71 + } 59 72 60 - case CMD_REQUEST_STATE: { 61 - if (_ready) { 62 - queueCurrentState(); 63 - } 64 - // If not ready, don't queue anything - calendar will retry 65 - break; 73 + case CMD_REQUEST_STATE: 74 + { 75 + if (_ready) 76 + { 77 + queueCurrentState(); 66 78 } 79 + // If not ready, don't queue anything - calendar will retry 80 + break; 81 + } 67 82 68 - case CMD_PING: { 69 - PendingCmd pong; 70 - pong.data[0] = RSP_PONG; 71 - pong.len = 1; 72 - pendingCmds.push(pong); 73 - break; 74 - } 83 + case CMD_PING: 84 + { 85 + Serial.println("I2C recv: ping"); 86 + PendingCmd pong; 87 + pong.data[0] = RSP_PONG; 88 + pong.len = 1; 89 + pendingCmds.push(pong); 90 + break; 91 + } 75 92 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; 93 + case CMD_CAL_STATE_PART1: 94 + { 95 + // Calendar sends its state for months 0-5 (24 bytes) 96 + if (numBytes >= 24) 97 + { 98 + uint8_t buffer[24]; 99 + for (int i = 0; i < 24; i++) 100 + { 101 + buffer[i] = Wire.read(); 86 102 } 87 - break; 103 + numBytes -= 24; 104 + memcpy(&_calState[0], buffer, 24); 105 + _calStatePart1Received = true; 88 106 } 107 + break; 108 + } 89 109 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; 110 + case CMD_CAL_STATE_PART2: 111 + { 112 + // Calendar sends its state for months 6-11 (24 bytes) 113 + if (numBytes >= 24) 114 + { 115 + uint8_t buffer[24]; 116 + for (int i = 0; i < 24; i++) 117 + { 118 + buffer[i] = Wire.read(); 100 119 } 101 - break; 120 + numBytes -= 24; 121 + memcpy(&_calState[6], buffer, 24); 122 + _calStatePart2Received = true; 102 123 } 124 + break; 125 + } 103 126 104 - default: 105 - break; 127 + default: 128 + Serial.printf("I2C recv: unknown command 0x%02X\n", cmd); 129 + break; 106 130 } 107 131 108 132 // Drain any remaining bytes 109 - while (Wire.available()) { 133 + while (Wire.available()) 134 + { 110 135 Wire.read(); 111 136 } 112 137 } 113 138 114 139 // Called when master requests data from us 115 - void CalendarI2C::onRequest() { 116 - if (!pendingCmds.empty()) { 117 - PendingCmd& cmd = pendingCmds.front(); 140 + void CalendarI2C::onRequest() 141 + { 142 + if (!pendingCmds.empty()) 143 + { 144 + PendingCmd &cmd = pendingCmds.front(); 118 145 Wire.write(cmd.data, cmd.len); 119 146 pendingCmds.pop(); 120 - } else { 147 + } 148 + else 149 + { 121 150 Wire.write(RSP_NONE); 122 151 } 123 152 } 124 153 125 154 // Queue state bitmap (2 parts to fit in Wire's 32-byte buffer) 126 - void CalendarI2C::queueCurrentState() { 155 + void CalendarI2C::queueCurrentState() 156 + { 127 157 // Clear the queue first 128 - while (!pendingCmds.empty()) { 158 + while (!pendingCmds.empty()) 159 + { 129 160 pendingCmds.pop(); 130 161 } 131 162 132 163 // Part 1: months 0-5 (1 type byte + 24 data bytes = 25 bytes) 133 164 PendingCmd part1; 134 165 part1.data[0] = RSP_STATE_PART1; 135 - memcpy(&part1.data[1], &_state[0], 24); // 6 months × 4 bytes 166 + memcpy(&part1.data[1], &_state[0], 24); // 6 months × 4 bytes 136 167 part1.len = 25; 137 168 pendingCmds.push(part1); 138 169 139 170 // Part 2: months 6-11 (1 type byte + 24 data bytes = 25 bytes) 140 171 PendingCmd part2; 141 172 part2.data[0] = RSP_STATE_PART2; 142 - memcpy(&part2.data[1], &_state[6], 24); // 6 months × 4 bytes 173 + memcpy(&part2.data[1], &_state[6], 24); // 6 months × 4 bytes 143 174 part2.len = 25; 144 175 pendingCmds.push(part2); 145 176 } 146 177 147 - bool CalendarI2C::hasButtonPress() { 178 + bool CalendarI2C::hasButtonPress() 179 + { 148 180 return !buttonQueue.empty(); 149 181 } 150 182 151 - CalendarButton CalendarI2C::getButtonPress() { 183 + CalendarButton CalendarI2C::getButtonPress() 184 + { 152 185 CalendarButton btn = buttonQueue.front(); 153 186 buttonQueue.pop(); 154 187 return btn; 155 188 } 156 189 157 - void CalendarI2C::setState(uint8_t month, uint8_t day, bool on) { 158 - if (month >= 12 || day >= 31) return; 190 + void CalendarI2C::setState(uint8_t month, uint8_t day, bool on) 191 + { 192 + if (month >= 12 || day >= 31) 193 + return; 159 194 160 - if (on) { 195 + if (on) 196 + { 161 197 _state[month] |= ((uint32_t)1 << day); 162 - } else { 198 + } 199 + else 200 + { 163 201 _state[month] &= ~((uint32_t)1 << day); 164 202 } 165 203 } 166 204 167 - void CalendarI2C::clearState() { 205 + void CalendarI2C::clearState() 206 + { 168 207 memset(_state, 0, sizeof(_state)); 169 208 } 170 209 171 - void CalendarI2C::setFullState(uint32_t* monthStates) { 210 + void CalendarI2C::setFullState(uint32_t *monthStates) 211 + { 172 212 memcpy(_state, monthStates, sizeof(_state)); 173 213 } 174 214 175 - bool CalendarI2C::getState(uint8_t month, uint8_t day) { 176 - if (month >= 12 || day >= 31) return false; 215 + bool CalendarI2C::getState(uint8_t month, uint8_t day) 216 + { 217 + if (month >= 12 || day >= 31) 218 + return false; 177 219 return (_state[month] & ((uint32_t)1 << day)) != 0; 178 220 } 179 221 180 - void CalendarI2C::setReady(bool ready) { 222 + void CalendarI2C::setReady(bool ready) 223 + { 181 224 _ready = ready; 182 225 } 183 226 184 - bool CalendarI2C::isReady() { 227 + bool CalendarI2C::isReady() 228 + { 185 229 return _ready; 186 230 } 187 231 188 - void CalendarI2C::requestCalendarState() { 232 + void CalendarI2C::requestCalendarState() 233 + { 189 234 // Queue a request for calendar to send its state 190 235 // This will be sent when calendar next polls us 191 236 _calStateRequested = true; ··· 199 244 pendingCmds.push(req); 200 245 } 201 246 202 - bool CalendarI2C::hasCalendarState() { 247 + bool CalendarI2C::hasCalendarState() 248 + { 203 249 return _calStatePart1Received && _calStatePart2Received; 204 250 } 205 251 206 - void CalendarI2C::getCalendarState(uint32_t* monthStates) { 252 + void CalendarI2C::getCalendarState(uint32_t *monthStates) 253 + { 207 254 memcpy(monthStates, _calState, sizeof(_calState)); 208 255 } 209 256 210 - void CalendarI2C::clearCalendarStateRequest() { 257 + void CalendarI2C::clearCalendarStateRequest() 258 + { 211 259 _calStateRequested = false; 212 260 _calStatePart1Received = false; 213 261 _calStatePart2Received = false; 214 262 } 215 263 216 - void CalendarI2C::setOnlineStatus(bool online) { 264 + void CalendarI2C::setOnlineStatus(bool online) 265 + { 217 266 PendingCmd cmd; 218 267 cmd.data[0] = RSP_SET_ONLINE; 219 268 cmd.data[1] = online ? 1 : 0;
+9 -1
firmware/libraries/EverydayCalendar/EverydayCalendar_i2c_sync.cpp
··· 2 2 #include "EverydayCalendar_lights.h" 3 3 #include <Wire.h> 4 4 5 - #define STATE_POLL_INTERVAL_MS 5000 // Poll ESP32 for state every 5 seconds 5 + #define STATE_POLL_INTERVAL_MS 1000 // Poll ESP32 for state every 1 second 6 6 #define CONNECTION_TIMEOUT_MS 10000 // Consider disconnected after 10s 7 7 8 8 void EverydayCalendar_i2c_sync::configure(EverydayCalendar_lights* lights) { ··· 29 29 void EverydayCalendar_i2c_sync::update() { 30 30 unsigned long now = millis(); 31 31 32 + // Track previous connection state for logging 33 + bool wasConnected = _connected; 34 + 32 35 // Poll ESP32 for state periodically 33 36 if (now - _lastPollTime >= STATE_POLL_INTERVAL_MS) { 34 37 _lastPollTime = now; ··· 42 45 Serial.println(F("I2C Sync: connection lost")); 43 46 _connected = false; 44 47 } 48 + } 49 + 50 + // Log connection established 51 + if (_connected && !wasConnected) { 52 + Serial.println(F("I2C Sync: ESP32 connected")); 45 53 } 46 54 } 47 55
+17 -5
firmware/libraries/EverydayCalendar/EverydayCalendar_lights.cpp
··· 138 138 *((byte*)ledValues + i) = EEPROM.read(EEPROM_START_ADR + i); 139 139 } 140 140 141 - for(int i=0; i<12; i++){ 142 - Serial.print("LED Column "); 143 - Serial.print(i); 144 - Serial.print(" = "); 145 - Serial.println(ledValues[i]); 141 + // Print compact calendar state 142 + static const char* monthNames[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", 143 + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; 144 + static const uint8_t daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; 145 + 146 + for (int month = 0; month < 12; month++) { 147 + Serial.print(monthNames[month]); 148 + Serial.print(F(": ")); 149 + 150 + for (int day = 0; day < daysInMonth[month]; day++) { 151 + if (ledValues[month] & ((uint32_t)1 << day)) { 152 + Serial.print(F("⊙")); 153 + } else { 154 + Serial.print(F("·")); 155 + } 156 + } 157 + Serial.println(); 146 158 } 147 159 148 160 loaded = true;
+32 -3
firmware/sketches/EverydayCalendar/EverydayCalendar.ino
··· 86 86 const uint8_t RIPPLE_MAX_RADIUS = 40; // In half-LED units (0.5 increments), so 20 LEDs max 87 87 const uint16_t RIPPLE_MS_PER_RADIUS = RIPPLE_DURATION_MS / RIPPLE_MAX_RADIUS; // 50ms 88 88 89 + // "Waiting for internet" corner animation 90 + // Cycles through 4 corners when ESP32 connected but no internet 91 + const Point CORNER_LEDS[] = { 92 + {0, 0}, // Jan 1st 93 + {11, 0}, // Dec 1st 94 + {11, 30}, // Dec 31st 95 + {0, 30} // Jan 31st 96 + }; 97 + const uint8_t CORNER_COUNT = 4; 98 + const uint16_t CORNER_MS_PER_LED = 1000; // 1 second per corner 99 + 89 100 90 101 void doBurst(Point p) { 91 102 for (size_t i = 0; i < BURST_COUNT; i++) ··· 137 148 Wire.begin(); 138 149 139 150 // Initialize I2C sync with ESP32 140 - // Calendar will poll ESP32 every 5 seconds for current state 151 + // Calendar will poll ESP32 every second for current state 141 152 cal_sync.configure(&cal_lights); 142 153 cal_sync.begin(); 143 154 ··· 177 188 // Process incoming sync commands from ESP32 178 189 cal_sync.update(); 179 190 180 - // Track online status changes - trigger ripple when connected 191 + // Track online status changes - trigger ripple on connect/disconnect 181 192 static bool previousOnlineStatus = false; 182 193 bool currentOnlineStatus = cal_sync.isOnline(); 183 - if (currentOnlineStatus && !previousOnlineStatus) { 194 + if (currentOnlineStatus != previousOnlineStatus) { 195 + if (currentOnlineStatus) { 196 + Serial.println(F("Internet: connected")); 197 + } else { 198 + Serial.println(F("Internet: disconnected")); 199 + } 184 200 doConnectedRipple(); 185 201 } 186 202 previousOnlineStatus = currentOnlineStatus; ··· 327 343 } 328 344 } 329 345 } 346 + } 347 + 348 + // Corner animation - waiting for internet (ESP32 connected but no internet) 349 + if (cal_sync.isConnected() && !cal_sync.isOnline()) { 350 + if (needsClearing) { 351 + cal_lights.clearOverrideLEDs(); 352 + needsClearing = false; 353 + } 354 + 355 + // Calculate which corner LED to show based on time 356 + uint8_t cornerIndex = (millis() / CORNER_MS_PER_LED) % CORNER_COUNT; 357 + const Point* corner = &CORNER_LEDS[cornerIndex]; 358 + cal_lights.setOverrideLED(corner->x, corner->y, true); 330 359 } 331 360 } 332 361