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.

Switch ESP32-calendar communication from serial to I2C

Serial communication failed due to Timer2 ISR blocking SoftwareSerial.
I2C is interrupt-driven and works reliably with LED multiplexing.

Changes:
- ESP32 acts as I2C slave at address 0x42 (calendar is master)
- Calendar polls ESP32 every 5 seconds for full state
- ESP32 maintains internal state, sends CLEAR + SET_LED commands
- Added "ready" flag so ESP32 doesn't respond before syncing with goals.garden
- Fixed connection timeout bug (millis() captured before poll completed)
- Added 200ms timeout to touch scanning to prevent hang when panel disconnected
- Removed old serial communication code (calendar_serial.cpp/h)

Hardware: Connect ESP32 STEMMA QT to calendar's J2 header (SDA/SCL)

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

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

+731 -206
+7 -5
README.md
··· 6 6 7 7 **New in this fork:** 8 8 9 - - WiFi sync with goals.garden (ATProto) via ESP32-S2 co-processor 9 + - WiFi sync with goals.garden (ATProto) via ESP32-S3 co-processor 10 10 - Real-time updates via Jetstream WebSocket 11 11 - Simplified brightness buttons (single tap for on/off) 12 12 - Light wave animation when enabling a day ··· 51 51 52 52 ### Hardware Setup for WiFi Sync 53 53 54 - Connect an ESP32-S2 (e.g., Adafruit QT Py) to the calendar's "Unused I/O" header: 54 + Connect an ESP32-S3 (e.g., Adafruit QT Py) to the calendar's "Unused I/O" header: 55 55 56 - - Calendar A2 (TX) -> ESP32 RX 57 - - Calendar A3 (RX) -> ESP32 TX 56 + - Calendar A2 (RX) <- ESP32 TX 57 + - Calendar A3 (TX) -> ESP32 RX 58 58 - GND -> GND 59 - - 3.3V -> 3.3V 59 + - 3.3V or 5V -> ESP32 power (see note below) 60 + 61 + **Power note:** You can use 3.3V from J1/J2 headers to the ESP32's 3V pin, or 5V from the barrel jack to the ESP32's 5V pin. Don't connect USB while externally powered. 60 62 61 63 The ESP32 will be available at `everydaycalendar.local` on your network. 62 64
+39 -27
firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino
··· 3 3 * 4 4 * This ESP32 firmware syncs the Every Day Calendar with goals.garden (ATProto) 5 5 * 6 - * Hardware: QT Py ESP32-S2 (or similar) 7 - * Connection: Serial to Arduino via Unused I/O header (A2/A3) 6 + * Hardware: QT Py ESP32-S3 (or similar) 7 + * Connection: I2C to Arduino via J2 header (SDA/SCL) 8 8 * 9 9 * Configuration: Copy config.local.h.example to config.local.h and fill in credentials 10 10 * ··· 23 23 #include <time.h> 24 24 25 25 #include "config.h" 26 - #include "calendar_serial.h" 26 + #include "calendar_i2c.h" 27 27 #include "atproto_client.h" 28 28 #include "jetstream_client.h" 29 29 ··· 40 40 String getISO8601Date(int year, int month, int day); 41 41 42 42 // Global objects 43 - CalendarSerial calendarSerial; 43 + CalendarI2C calendarI2C; 44 44 ATProtoClient atproto; 45 45 JetstreamClient jetstream; 46 46 ··· 61 61 Serial.println(F("\n\n=== Goals Garden Sync ===")); 62 62 Serial.println(F("Starting up...")); 63 63 64 - // Initialize serial communication with calendar 65 - calendarSerial.begin(); 64 + // Initialize I2C communication with calendar 65 + calendarI2C.begin(CALENDAR_I2C_SDA, CALENDAR_I2C_SCL); 66 66 67 67 // Setup WiFi 68 68 setupWiFi(); ··· 94 94 return; 95 95 } 96 96 97 - // Process serial communication with calendar 98 - calendarSerial.update(); 97 + // Process I2C communication with calendar 98 + // (Calendar polls for state automatically, ESP32 responds from internal state) 99 + calendarI2C.update(); 99 100 100 101 // Check for button presses from calendar 101 - if (calendarSerial.hasButtonPress()) { 102 - CalendarButton btn = calendarSerial.getButtonPress(); 102 + if (calendarI2C.hasButtonPress()) { 103 + CalendarButton btn = calendarI2C.getButtonPress(); 103 104 handleCalendarButton(btn.month, btn.day, btn.state); 104 105 } 105 106 ··· 114 115 } 115 116 } 116 117 117 - // Periodic full sync 118 + // Periodic full sync from goals.garden to update internal state 118 119 if (atprotoConnected && (millis() - lastSyncTime > SYNC_INTERVAL_MS)) { 119 120 performFullSync(); 120 121 } ··· 241 242 Serial.print(F("DID: ")); 242 243 Serial.println(atproto.getDid()); 243 244 244 - // Start Jetstream subscription and perform initial sync 245 + // Start Jetstream subscription 245 246 jetstream.begin(atproto.getDid()); 247 + 248 + // Wait for calendar to finish its startup animation before syncing 249 + // Calendar takes ~5s for honey drip + fade + delays 250 + Serial.println(F("Waiting for calendar to be ready...")); 251 + delay(6000); 252 + 246 253 performFullSync(); 247 254 } else { 248 255 Serial.println(F("ATProto connection failed")); ··· 295 302 Serial.printf("Created completion: %s\n", rkey.c_str()); 296 303 } else { 297 304 Serial.println(F("Failed to create completion")); 298 - // Revert the LED state 299 - calendarSerial.setLED(month, day, false); 305 + // Revert the internal state (calendar will pick up on next poll) 306 + calendarI2C.setState(month, day, false); 300 307 } 301 308 } else { 302 309 // Delete completion record ··· 306 313 Serial.printf("Deleted completion: %s\n", rkey.c_str()); 307 314 } else { 308 315 Serial.println(F("Failed to delete completion")); 309 - // Revert the LED state 310 - calendarSerial.setLED(month, day, true); 316 + // Revert the internal state (calendar will pick up on next poll) 317 + calendarI2C.setState(month, day, true); 311 318 } 312 319 } 313 320 } ··· 336 343 uint8_t calMonth = event.month - 1; // 0-11 337 344 uint8_t calDay = event.day - 1; // 0-30 338 345 346 + // Update internal state (calendar will pick up on next poll) 339 347 if (event.action == JetstreamAction::Create) { 340 - calendarSerial.setLED(calMonth, calDay, true); 348 + calendarI2C.setState(calMonth, calDay, true); 341 349 } else if (event.action == JetstreamAction::Delete) { 342 - calendarSerial.setLED(calMonth, calDay, false); 350 + calendarI2C.setState(calMonth, calDay, false); 343 351 } 344 352 } 345 353 346 354 void performFullSync() { 347 355 if (!atprotoConnected) return; 348 356 349 - Serial.println(F("Performing full sync...")); 357 + Serial.println(F("Performing full sync from goals.garden...")); 350 358 351 359 // Get current year 352 360 time_t now = time(nullptr); 353 361 struct tm timeinfo; 354 362 localtime_r(&now, &timeinfo); 355 363 int currentYear = timeinfo.tm_year + 1900; 356 - 357 - // Clear calendar first 358 - calendarSerial.sendCommand("CLEAR"); 359 - delay(100); 360 364 361 365 // Fetch all completions for this goal 362 366 std::vector<Completion> completions = atproto.getCompletions(goalUri, currentYear); 363 367 364 - // Set LEDs for each completion 368 + // Build state array 369 + uint32_t monthStates[12] = {0}; 365 370 for (const auto& completion : completions) { 366 371 if (completion.year == currentYear) { 367 372 uint8_t calMonth = completion.month - 1; // 0-11 368 373 uint8_t calDay = completion.day - 1; // 0-30 369 - calendarSerial.setLED(calMonth, calDay, true); 370 - delay(10); // Small delay between commands 374 + if (calMonth < 12 && calDay < 31) { 375 + monthStates[calMonth] |= ((uint32_t)1 << calDay); 376 + } 371 377 } 372 378 } 373 379 380 + // Update internal state (calendar will pick up on next poll) 381 + calendarI2C.setFullState(monthStates); 382 + 374 383 // Store completion rkeys for later deletion lookups 375 384 atproto.cacheCompletions(completions); 376 385 377 386 // Update sync timestamp to prevent immediate re-sync 378 387 lastSyncTime = millis(); 379 388 380 - Serial.println(F("Full sync complete")); 389 + // Mark ESP32 as ready - calendar can now receive valid state 390 + calendarI2C.setReady(true); 391 + 392 + Serial.printf("Full sync complete: %d completions\n", completions.size()); 381 393 } 382 394 383 395 String findCompletionRkey(int year, int month, int day) {
+194
firmware/esp32/GoalsGardenSync/calendar_i2c.cpp
··· 1 + #include "calendar_i2c.h" 2 + #include "config.h" 3 + 4 + // Global instance for static callbacks 5 + CalendarI2C* g_calendarI2C = nullptr; 6 + 7 + void CalendarI2C::begin(int sdaPin, int sclPin) { 8 + 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 + 13 + Wire.begin(CALENDAR_I2C_ADDR, sdaPin, sclPin, 100000); // 100kHz 14 + Wire.onReceive(onReceiveStatic); 15 + Wire.onRequest(onRequestStatic); 16 + 17 + Serial.println(F("Calendar I2C slave initialized")); 18 + } 19 + 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 + } 34 + } 35 + 36 + // Static callback wrapper 37 + void CalendarI2C::onReceiveStatic(int numBytes) { 38 + if (g_calendarI2C) { 39 + g_calendarI2C->onReceive(numBytes); 40 + } 41 + } 42 + 43 + // Static callback wrapper 44 + void CalendarI2C::onRequestStatic() { 45 + if (g_calendarI2C) { 46 + g_calendarI2C->onRequest(); 47 + } 48 + } 49 + 50 + // Called when master sends data to us 51 + void CalendarI2C::onReceive(int numBytes) { 52 + if (numBytes < 1) return; 53 + 54 + uint8_t cmd = Wire.read(); 55 + numBytes--; 56 + 57 + switch (cmd) { 58 + case CMD_BUTTON_PRESS: { 59 + if (numBytes >= 3) { 60 + CalendarButton btn; 61 + btn.month = Wire.read(); 62 + btn.day = Wire.read(); 63 + btn.state = Wire.read() != 0; 64 + buttonQueue.push(btn); 65 + 66 + // Update internal state immediately 67 + setState(btn.month, btn.day, btn.state); 68 + 69 + Serial.printf("I2C: Button month=%d, day=%d, state=%d\n", 70 + btn.month, btn.day, btn.state); 71 + } 72 + break; 73 + } 74 + 75 + case CMD_REQUEST_STATE: { 76 + if (_ready) { 77 + Serial.println(F("I2C: State requested - queueing current state")); 78 + queueCurrentState(); 79 + } else { 80 + Serial.println(F("I2C: State requested but not ready yet")); 81 + // Don't queue anything - calendar will retry 82 + } 83 + break; 84 + } 85 + 86 + case CMD_PING: { 87 + Serial.println(F("I2C: Ping received")); 88 + PendingCmd cmd; 89 + cmd.type = RSP_PONG; 90 + cmd.data[0] = RSP_PONG; 91 + cmd.len = 1; 92 + pendingCmds.push(cmd); 93 + break; 94 + } 95 + 96 + default: 97 + Serial.printf("I2C: Unknown command 0x%02X\n", cmd); 98 + break; 99 + } 100 + 101 + // Drain any remaining bytes 102 + while (Wire.available()) { 103 + Wire.read(); 104 + } 105 + } 106 + 107 + // Called when master requests data from us 108 + void CalendarI2C::onRequest() { 109 + if (!pendingCmds.empty()) { 110 + PendingCmd& cmd = pendingCmds.front(); 111 + Wire.write(cmd.data, cmd.len); 112 + pendingCmds.pop(); 113 + } else { 114 + Wire.write(RSP_NONE); 115 + } 116 + } 117 + 118 + // Queue CLEAR + SET_LED commands for current state 119 + void CalendarI2C::queueCurrentState() { 120 + // Clear the queue first 121 + while (!pendingCmds.empty()) { 122 + pendingCmds.pop(); 123 + } 124 + 125 + // Queue CLEAR command 126 + PendingCmd clearCmd; 127 + clearCmd.type = RSP_CLEAR; 128 + clearCmd.data[0] = RSP_CLEAR; 129 + clearCmd.len = 1; 130 + pendingCmds.push(clearCmd); 131 + 132 + // Queue SET_LED for each lit day 133 + int count = 0; 134 + for (uint8_t month = 0; month < 12; month++) { 135 + for (uint8_t day = 0; day < 31; day++) { 136 + if (_state[month] & ((uint32_t)1 << day)) { 137 + PendingCmd cmd; 138 + cmd.type = RSP_SET_LED; 139 + cmd.data[0] = RSP_SET_LED; 140 + cmd.data[1] = month; 141 + cmd.data[2] = day; 142 + cmd.data[3] = 1; 143 + cmd.len = 4; 144 + pendingCmds.push(cmd); 145 + count++; 146 + } 147 + } 148 + } 149 + Serial.printf("I2C: Queued CLEAR + %d SET_LEDs\n", count); 150 + } 151 + 152 + bool CalendarI2C::hasButtonPress() { 153 + return !buttonQueue.empty(); 154 + } 155 + 156 + CalendarButton CalendarI2C::getButtonPress() { 157 + CalendarButton btn = buttonQueue.front(); 158 + buttonQueue.pop(); 159 + return btn; 160 + } 161 + 162 + void CalendarI2C::setState(uint8_t month, uint8_t day, bool on) { 163 + if (month >= 12 || day >= 31) return; 164 + 165 + if (on) { 166 + _state[month] |= ((uint32_t)1 << day); 167 + } else { 168 + _state[month] &= ~((uint32_t)1 << day); 169 + } 170 + } 171 + 172 + void CalendarI2C::clearState() { 173 + memset(_state, 0, sizeof(_state)); 174 + } 175 + 176 + void CalendarI2C::setFullState(uint32_t* monthStates) { 177 + memcpy(_state, monthStates, sizeof(_state)); 178 + } 179 + 180 + bool CalendarI2C::getState(uint8_t month, uint8_t day) { 181 + if (month >= 12 || day >= 31) return false; 182 + return (_state[month] & ((uint32_t)1 << day)) != 0; 183 + } 184 + 185 + void CalendarI2C::setReady(bool ready) { 186 + _ready = ready; 187 + if (ready) { 188 + Serial.println(F("I2C: ESP32 marked as ready")); 189 + } 190 + } 191 + 192 + bool CalendarI2C::isReady() { 193 + return _ready; 194 + }
+76
firmware/esp32/GoalsGardenSync/calendar_i2c.h
··· 1 + #ifndef CALENDAR_I2C_H 2 + #define CALENDAR_I2C_H 3 + 4 + #include <Arduino.h> 5 + #include <Wire.h> 6 + #include <queue> 7 + 8 + // I2C slave address for ESP32 (must not conflict with IQS5xx at 0x74) 9 + #define CALENDAR_I2C_ADDR 0x42 10 + 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 15 + 16 + // Response codes to master (ESP32 -> Calendar) 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 20 + #define RSP_NONE 0x00 // No pending response 21 + 22 + struct CalendarButton { 23 + uint8_t month; 24 + uint8_t day; 25 + bool state; 26 + }; 27 + 28 + class CalendarI2C { 29 + public: 30 + void begin(int sdaPin, int sclPin); 31 + void update(); 32 + 33 + // Check if there's a button press event from calendar 34 + bool hasButtonPress(); 35 + CalendarButton getButtonPress(); 36 + 37 + // Update internal state (called when goals.garden data changes) 38 + void setState(uint8_t month, uint8_t day, bool on); 39 + void clearState(); 40 + void setFullState(uint32_t* monthStates); 41 + 42 + // Mark ESP32 as ready (has valid data from goals.garden) 43 + void setReady(bool ready); 44 + bool isReady(); 45 + 46 + // Get current state (for syncing to goals.garden) 47 + bool getState(uint8_t month, uint8_t day); 48 + 49 + // Static callbacks for Wire library 50 + static void onReceiveStatic(int numBytes); 51 + static void onRequestStatic(); 52 + 53 + private: 54 + void onReceive(int numBytes); 55 + void onRequest(); 56 + void queueCurrentState(); 57 + 58 + // Internal state - ESP32 is source of truth 59 + uint32_t _state[12] = {0}; 60 + bool _ready = false; // True after first sync with goals.garden 61 + 62 + std::queue<CalendarButton> buttonQueue; 63 + 64 + // Pending commands to send to calendar 65 + struct PendingCmd { 66 + uint8_t type; 67 + uint8_t data[4]; // Max: type + month + day + state 68 + uint8_t len; 69 + }; 70 + std::queue<PendingCmd> pendingCmds; 71 + }; 72 + 73 + // Global instance for static callbacks 74 + extern CalendarI2C* g_calendarI2C; 75 + 76 + #endif
-101
firmware/esp32/GoalsGardenSync/calendar_serial.cpp
··· 1 - #include "calendar_serial.h" 2 - #include "config.h" 3 - 4 - // Use Serial1 for calendar communication 5 - #define CalSerial Serial1 6 - 7 - void CalendarSerial::begin() { 8 - CalSerial.begin(CALENDAR_BAUD_RATE, SERIAL_8N1, CALENDAR_RX_PIN, CALENDAR_TX_PIN); 9 - Serial.println(F("Calendar serial initialized")); 10 - 11 - // Send a ping to check connection 12 - delay(100); 13 - CalSerial.println(F("PING")); 14 - } 15 - 16 - void CalendarSerial::update() { 17 - while (CalSerial.available()) { 18 - char c = CalSerial.read(); 19 - 20 - if (c == '\n' || c == '\r') { 21 - if (cmdIndex > 0) { 22 - cmdBuffer[cmdIndex] = '\0'; 23 - processLine(); 24 - cmdIndex = 0; 25 - } 26 - } else if (cmdIndex < sizeof(cmdBuffer) - 1) { 27 - cmdBuffer[cmdIndex++] = c; 28 - } 29 - } 30 - } 31 - 32 - void CalendarSerial::processLine() { 33 - Serial.print(F("Calendar: ")); 34 - Serial.println(cmdBuffer); 35 - 36 - if (strncmp(cmdBuffer, "BTN,", 4) == 0) { 37 - parseButtonPress(cmdBuffer + 4); 38 - } else if (strncmp(cmdBuffer, "STATE,", 6) == 0) { 39 - parseState(cmdBuffer + 6); 40 - } else if (strcmp(cmdBuffer, "OK") == 0) { 41 - // Acknowledgment, nothing to do 42 - } else if (strcmp(cmdBuffer, "PONG") == 0) { 43 - Serial.println(F("Calendar connection confirmed")); 44 - } else if (strncmp(cmdBuffer, "ERR,", 4) == 0) { 45 - Serial.print(F("Calendar error: ")); 46 - Serial.println(cmdBuffer + 4); 47 - } 48 - } 49 - 50 - void CalendarSerial::parseButtonPress(const char* args) { 51 - int month, day, state; 52 - if (sscanf(args, "%d,%d,%d", &month, &day, &state) == 3) { 53 - CalendarButton btn; 54 - btn.month = month; 55 - btn.day = day; 56 - btn.state = (state != 0); 57 - buttonQueue.push(btn); 58 - 59 - Serial.printf("Button press queued: month=%d, day=%d, state=%d\n", 60 - month, day, state); 61 - } 62 - } 63 - 64 - void CalendarSerial::parseState(const char* args) { 65 - // Parse comma-separated hex values for each month 66 - // STATE,<m0>,<m1>,...<m11> 67 - Serial.println(F("Received calendar state")); 68 - // This could be used for initial sync verification 69 - } 70 - 71 - bool CalendarSerial::hasButtonPress() { 72 - return !buttonQueue.empty(); 73 - } 74 - 75 - CalendarButton CalendarSerial::getButtonPress() { 76 - CalendarButton btn = buttonQueue.front(); 77 - buttonQueue.pop(); 78 - return btn; 79 - } 80 - 81 - void CalendarSerial::setLED(uint8_t month, uint8_t day, bool on) { 82 - char cmd[32]; 83 - snprintf(cmd, sizeof(cmd), "SET,%d,%d,%d", month, day, on ? 1 : 0); 84 - CalSerial.println(cmd); 85 - Serial.printf("Sent: %s\n", cmd); 86 - } 87 - 88 - void CalendarSerial::requestSync() { 89 - CalSerial.println(F("SYNC")); 90 - Serial.println(F("Sent: SYNC")); 91 - } 92 - 93 - void CalendarSerial::clearAll() { 94 - CalSerial.println(F("CLEAR")); 95 - Serial.println(F("Sent: CLEAR")); 96 - } 97 - 98 - void CalendarSerial::sendCommand(const char* cmd) { 99 - CalSerial.println(cmd); 100 - Serial.printf("Sent: %s\n", cmd); 101 - }
-38
firmware/esp32/GoalsGardenSync/calendar_serial.h
··· 1 - #ifndef CALENDAR_SERIAL_H 2 - #define CALENDAR_SERIAL_H 3 - 4 - #include <Arduino.h> 5 - #include <queue> 6 - 7 - struct CalendarButton { 8 - uint8_t month; 9 - uint8_t day; 10 - bool state; 11 - }; 12 - 13 - class CalendarSerial { 14 - public: 15 - void begin(); 16 - void update(); 17 - 18 - // Check if there's a button press event 19 - bool hasButtonPress(); 20 - CalendarButton getButtonPress(); 21 - 22 - // Send commands to calendar 23 - void setLED(uint8_t month, uint8_t day, bool on); 24 - void requestSync(); 25 - void clearAll(); 26 - void sendCommand(const char* cmd); 27 - 28 - private: 29 - char cmdBuffer[128]; 30 - uint8_t cmdIndex = 0; 31 - std::queue<CalendarButton> buttonQueue; 32 - 33 - void processLine(); 34 - void parseButtonPress(const char* args); 35 - void parseState(const char* args); 36 - }; 37 - 38 - #endif
+9 -5
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 - // Serial pins for communication with calendar 15 - // QT Py ESP32-S2 pins - adjust if using different board 16 - #define CALENDAR_RX_PIN 5 // ESP32 RX <- Arduino TX (A3) 17 - #define CALENDAR_TX_PIN 6 // ESP32 TX -> Arduino RX (A2) 18 - #define CALENDAR_BAUD_RATE 9600 14 + // I2C pins for communication with calendar 15 + // QT Py ESP32-S3: STEMMA QT connector to Calendar's J2 header (SDA/SCL) 16 + #define CALENDAR_I2C_SDA 41 // ESP32 STEMMA QT SDA (GPIO41) -> Calendar J2 SDA 17 + #define CALENDAR_I2C_SCL 40 // ESP32 STEMMA QT SCL (GPIO40) -> Calendar J2 SCL 18 + 19 + // Legacy serial pins (no longer used - I2C is more reliable) 20 + // #define CALENDAR_RX_PIN 5 21 + // #define CALENDAR_TX_PIN 16 22 + // #define CALENDAR_BAUD_RATE 9600 19 23 20 24 // Slingshot API for resolving PDS from identifier 21 25 #define SLINGSHOT_API "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier="
+176
firmware/libraries/EverydayCalendar/EverydayCalendar_i2c_sync.cpp
··· 1 + #include "EverydayCalendar_i2c_sync.h" 2 + #include "EverydayCalendar_lights.h" 3 + #include <Wire.h> 4 + 5 + #define STATE_POLL_INTERVAL_MS 5000 // Poll ESP32 for state every 5 seconds 6 + #define CONNECTION_TIMEOUT_MS 10000 // Consider disconnected after 10s 7 + 8 + void EverydayCalendar_i2c_sync::configure(EverydayCalendar_lights* lights) { 9 + _lights = lights; 10 + _lastPollTime = 0; 11 + _lastResponseTime = 0; 12 + _connected = false; 13 + } 14 + 15 + void EverydayCalendar_i2c_sync::begin() { 16 + // Wire.begin() should already be called 17 + Serial.println(F("I2C Sync: initialized")); 18 + 19 + // Try a ping to see if ESP32 is there 20 + if (ping()) { 21 + Serial.println(F("I2C Sync: ESP32 responded")); 22 + _connected = true; 23 + } else { 24 + Serial.println(F("I2C Sync: ESP32 not responding yet")); 25 + } 26 + } 27 + 28 + void EverydayCalendar_i2c_sync::update() { 29 + unsigned long now = millis(); 30 + 31 + // Poll ESP32 for state periodically 32 + if (now - _lastPollTime >= STATE_POLL_INTERVAL_MS) { 33 + _lastPollTime = now; 34 + requestState(); 35 + } 36 + 37 + // Update connection status (re-read millis since requestState takes time) 38 + now = millis(); 39 + if (_lastResponseTime > 0 && (now - _lastResponseTime > CONNECTION_TIMEOUT_MS)) { 40 + if (_connected) { 41 + Serial.println(F("I2C Sync: connection lost")); 42 + _connected = false; 43 + } 44 + } 45 + } 46 + 47 + void EverydayCalendar_i2c_sync::requestState() { 48 + Serial.println(F("I2C Sync: polling ESP32...")); 49 + 50 + // Send state request to ESP32 51 + Wire.beginTransmission(ESP32_I2C_ADDR); 52 + Wire.write(CMD_REQUEST_STATE); 53 + Serial.println(F("I2C Sync: sending...")); 54 + uint8_t error = Wire.endTransmission(); 55 + Serial.print(F("I2C Sync: endTransmission returned ")); 56 + Serial.println(error); 57 + 58 + if (error != 0) { 59 + Serial.print(F("I2C Sync: no response (error ")); 60 + Serial.print(error); 61 + Serial.println(F(")")); 62 + return; 63 + } 64 + 65 + // 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...")); 68 + uint8_t responseCount = 0; 69 + for (int i = 0; i < 50; i++) { // Max 50 responses (CLEAR + up to 49 SET_LEDs) 70 + delay(10); // Small delay between polls 71 + 72 + uint8_t bytesReceived = Wire.requestFrom((uint8_t)ESP32_I2C_ADDR, (uint8_t)8); 73 + 74 + if (bytesReceived > 0) { 75 + uint8_t buffer[8]; 76 + uint8_t idx = 0; 77 + 78 + while (Wire.available() && idx < 8) { 79 + buffer[idx++] = Wire.read(); 80 + } 81 + 82 + if (idx > 0 && buffer[0] != RSP_NONE) { 83 + processResponse(buffer, idx); 84 + _lastResponseTime = millis(); 85 + _connected = true; 86 + responseCount++; 87 + } else { 88 + // No more data 89 + break; 90 + } 91 + } else { 92 + break; 93 + } 94 + } 95 + 96 + if (responseCount > 0) { 97 + Serial.print(F("I2C Sync: got ")); 98 + Serial.print(responseCount); 99 + Serial.println(F(" responses")); 100 + } 101 + } 102 + 103 + void EverydayCalendar_i2c_sync::processResponse(uint8_t* data, uint8_t len) { 104 + if (len < 1) return; 105 + 106 + uint8_t rspType = data[0]; 107 + 108 + switch (rspType) { 109 + case RSP_PONG: 110 + // Ping response - nothing to do 111 + break; 112 + 113 + case RSP_SET_LED: { 114 + if (len >= 4) { 115 + uint8_t month = data[1]; 116 + uint8_t day = data[2]; 117 + bool on = data[3] != 0; 118 + 119 + if (month < 12 && day < 31) { 120 + _lights->setLED(month, day, on); 121 + } 122 + } 123 + break; 124 + } 125 + 126 + case RSP_CLEAR: { 127 + _lights->clearAllLEDs(); 128 + break; 129 + } 130 + 131 + default: 132 + break; 133 + } 134 + } 135 + 136 + void EverydayCalendar_i2c_sync::sendButtonPress(uint8_t month, uint8_t day, bool newState) { 137 + Wire.beginTransmission(ESP32_I2C_ADDR); 138 + Wire.write(CMD_BUTTON_PRESS); 139 + Wire.write(month); 140 + Wire.write(day); 141 + Wire.write(newState ? 1 : 0); 142 + uint8_t error = Wire.endTransmission(); 143 + 144 + if (error == 0) { 145 + _lastResponseTime = millis(); 146 + _connected = true; 147 + } 148 + } 149 + 150 + bool EverydayCalendar_i2c_sync::ping() { 151 + Wire.beginTransmission(ESP32_I2C_ADDR); 152 + Wire.write(CMD_PING); 153 + uint8_t error = Wire.endTransmission(); 154 + 155 + if (error != 0) { 156 + return false; 157 + } 158 + 159 + delay(10); 160 + 161 + uint8_t bytesReceived = Wire.requestFrom((uint8_t)ESP32_I2C_ADDR, (uint8_t)1); 162 + if (bytesReceived > 0) { 163 + uint8_t response = Wire.read(); 164 + if (response == RSP_PONG) { 165 + _lastResponseTime = millis(); 166 + _connected = true; 167 + return true; 168 + } 169 + } 170 + 171 + return false; 172 + } 173 + 174 + bool EverydayCalendar_i2c_sync::isConnected() { 175 + return _connected; 176 + }
+50
firmware/libraries/EverydayCalendar/EverydayCalendar_i2c_sync.h
··· 1 + #ifndef EVERYDAYCALENDAR_I2C_SYNC_H 2 + #define EVERYDAYCALENDAR_I2C_SYNC_H 3 + 4 + #include <Arduino.h> 5 + 6 + class EverydayCalendar_lights; 7 + 8 + // I2C slave address for ESP32 (must match ESP32 side) 9 + #define ESP32_I2C_ADDR 0x42 10 + 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 15 + 16 + // Response codes from ESP32 (ESP32 -> Calendar) 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 20 + #define RSP_NONE 0x00 // No pending response 21 + 22 + class EverydayCalendar_i2c_sync { 23 + public: 24 + void configure(EverydayCalendar_lights* lights); 25 + void begin(); 26 + void update(); 27 + 28 + // Call when a button is pressed to notify ESP32 29 + void sendButtonPress(uint8_t month, uint8_t day, bool newState); 30 + 31 + // Check if ESP32 is responding 32 + bool ping(); 33 + 34 + // Check if sync is connected 35 + bool isConnected(); 36 + 37 + private: 38 + EverydayCalendar_lights* _lights; 39 + unsigned long _lastPollTime; 40 + unsigned long _lastResponseTime; 41 + bool _connected; 42 + 43 + // Request and process state from ESP32 44 + void requestState(); 45 + 46 + // Process a response from ESP32 47 + void processResponse(uint8_t* data, uint8_t len); 48 + }; 49 + 50 + #endif
+10 -1
firmware/libraries/EverydayCalendar/EverydayCalendar_lights.cpp
··· 55 55 // We want CTC mode (mode 2) where timer resets after compare 56 56 TCCR2A = (TCCR2A & ~0x03) | 0x00; 57 57 TCCR2B = (TCCR2B & ~0x08) | 0x00; 58 - TCCR2B = (TCCR2B & ~0x07) | 0x02; // selects a clock prescaler of 8. That's a frequency of 31372.55 58 + TCCR2B = (TCCR2B & ~0x07) | 0x02; // prescaler of 8 (original setting) 59 59 OCR2A = BRIGHTNESS_INITIAL_X10 / 10; 60 60 clearAllLEDs(); 61 61 } ··· 164 164 bool EverydayCalendar_lights::isLedOn(uint8_t month, uint8_t day){ 165 165 if (month > 11 || day > 30) return false; 166 166 return (ledValues[month] & ((uint32_t)1 << day)) != 0; 167 + } 168 + 169 + void EverydayCalendar_lights::pauseMultiplexing(){ 170 + TIMSK2 &= ~((1<<OCIE2A) | (1<<TOIE2)); // Disable Timer2 interrupts 171 + digitalWrite(outputEnablePin, HIGH); // Turn off LEDs 172 + } 173 + 174 + void EverydayCalendar_lights::resumeMultiplexing(){ 175 + TIMSK2 |= (1<<OCIE2A) | (1<<TOIE2); // Re-enable Timer2 interrupts 167 176 } 168 177 169 178
+4
firmware/libraries/EverydayCalendar/EverydayCalendar_lights.h
··· 23 23 uint32_t getLedState(uint8_t month); 24 24 // Check if a specific LED is on 25 25 bool isLedOn(uint8_t month, uint8_t day); 26 + 27 + // Pause/resume LED multiplexing (for serial receive) 28 + void pauseMultiplexing(); 29 + void resumeMultiplexing(); 26 30 }; 27 31 28 32 #endif
+89 -4
firmware/libraries/EverydayCalendar/EverydayCalendar_sync.cpp
··· 2 2 #include "EverydayCalendar_lights.h" 3 3 #include <Arduino.h> 4 4 5 - #define SYNC_BAUD_RATE 9600 6 - #define COMMAND_TIMEOUT_MS 30000 // Consider disconnected after 30s of no commands 5 + #define SYNC_BAUD_RATE 9600 // NeoSWSerial supports 9600, 19200, 31250, 38400 6 + #define COMMAND_TIMEOUT_MS 30000 7 7 8 8 void EverydayCalendar_sync::configure(uint8_t rxPin, uint8_t txPin, EverydayCalendar_lights* lights) { 9 - _serial = new SoftwareSerial(rxPin, txPin); 9 + _serial = new NeoSWSerial(rxPin, txPin); 10 10 _lights = lights; 11 11 _cmdIndex = 0; 12 12 _lastCommandTime = 0; ··· 14 14 15 15 void EverydayCalendar_sync::begin() { 16 16 _serial->begin(SYNC_BAUD_RATE); 17 - Serial.println(F("Sync: initialized")); 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")); 18 23 } 19 24 20 25 void EverydayCalendar_sync::update() { 26 + static unsigned long lastDebug = 0; 27 + static uint16_t byteCount = 0; 28 + 29 + // Process incoming serial data 21 30 while (_serial->available()) { 22 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("'")); 23 40 24 41 if (c == '\n' || c == '\r') { 25 42 if (_cmdIndex > 0) { ··· 30 47 } else if (_cmdIndex < sizeof(_cmdBuffer) - 1) { 31 48 _cmdBuffer[_cmdIndex++] = c; 32 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(); 33 61 } 34 62 } 35 63 ··· 118 146 if (_lastCommandTime == 0) return false; 119 147 return (millis() - _lastCommandTime) < COMMAND_TIMEOUT_MS; 120 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 + }
+14 -5
firmware/libraries/EverydayCalendar/EverydayCalendar_sync.h
··· 2 2 #define __EVERYDAYCALENDAR_SYNC_H 3 3 4 4 #include <stdint.h> 5 - #include <SoftwareSerial.h> 5 + #include <NeoSWSerial.h> 6 6 7 7 // Forward declarations 8 8 class EverydayCalendar_lights; 9 9 10 - // Serial protocol for ESP32 sync 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 + // 11 14 // Commands from ESP32 to Arduino: 12 15 // SET,<month>,<day>,<on>\n - Set LED state (month 0-11, day 0-30, on 0/1) 13 16 // SYNC\n - Request full state dump 14 17 // CLEAR\n - Clear all LEDs 18 + // PING\n - Connection check 15 19 // 16 20 // Commands from Arduino to ESP32: 17 - // BTN,<month>,<day>,<on>\n - Button pressed, LED toggled to state (month 0-11, day 0-30) 21 + // BTN,<month>,<day>,<on>\n - Button pressed, LED toggled to state 18 22 // STATE,<m0>,<m1>,...<m11>\n - Full state: 12 hex values (uint32), one per month 19 23 // OK\n - Acknowledgment 24 + // PONG\n - Response to PING 20 25 // ERR,<msg>\n - Error message 21 26 22 27 class EverydayCalendar_sync 23 28 { 24 29 public: 25 - // Initialize with pins for SoftwareSerial (A2=16, A3=17 on ATmega328P) 30 + // Initialize with pins (A2=16, A3=17 on ATmega328P) 26 31 void configure(uint8_t rxPin, uint8_t txPin, EverydayCalendar_lights* lights); 27 32 void begin(); 28 33 ··· 35 40 // Check if sync is connected (received any valid command recently) 36 41 bool isConnected(); 37 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 + 38 47 private: 39 - SoftwareSerial* _serial; 48 + NeoSWSerial* _serial; 40 49 EverydayCalendar_lights* _lights; 41 50 42 51 char _cmdBuffer[64];
+16 -3
firmware/libraries/EverydayCalendar/EverydayCalendar_touch.cpp
··· 99 99 100 100 // This whole procedure should take about 100 milliseconds 101 101 bool EverydayCalendar_touch::scanForTouch(){ 102 - // Wait until all touch controllers are ready 102 + const unsigned long TOUCH_TIMEOUT_MS = 200; // Max time to wait for touch controllers 103 + 104 + // Wait until all touch controllers are ready (with timeout) 105 + unsigned long startTime = millis(); 103 106 for(int i=0; i<4; i++){ 104 107 if(controllersEnabled[i] == false){ continue; } 105 108 if(tc[i].isReady() == false){ 109 + if (millis() - startTime > TOUCH_TIMEOUT_MS) { 110 + // Timeout - touch panel may be disconnected 111 + return false; 112 + } 106 113 i=0; // One of the controllers isn't ready. Start again from the top. 107 114 continue; 108 115 } ··· 112 119 if(controllersEnabled[i] == false){ continue; } 113 120 // Close session for controller 114 121 tc[i].endSession(); 115 - // Initiates a reading 116 - while(tc[i].isReady() == false); 122 + // Initiates a reading (with timeout) 123 + startTime = millis(); 124 + while(tc[i].isReady() == false) { 125 + if (millis() - startTime > TOUCH_TIMEOUT_MS) { 126 + // Timeout - touch panel may be disconnected 127 + return false; 128 + } 129 + } 117 130 tc[i].readTouch(); 118 131 if(tc[i].atiErrorDetected()){ 119 132 Serial.print(" ATI Error! \tDevice Address: 0x");
+38 -10
firmware/sketches/EverydayCalendar/EverydayCalendar.ino
··· 1 + #include <Wire.h> 1 2 #include <EverydayCalendar_lights.h> 2 3 #include <EverydayCalendar_touch.h> 3 - #include <EverydayCalendar_sync.h> 4 + #include <EverydayCalendar_i2c_sync.h> 4 5 5 - // Sync pins - using "Unused I/O" header on the PCB 6 - #define SYNC_RX_PIN A2 // Receive from ESP32 7 - #define SYNC_TX_PIN A3 // Transmit to ESP32 6 + // I2C sync uses the same bus as the touch sensor (A4=SDA, A5=SCL) 7 + // Connect ESP32 to the J2 header's SDA/SCL pins 8 8 9 9 typedef struct { 10 10 int8_t x; ··· 18 18 19 19 EverydayCalendar_touch cal_touch; 20 20 EverydayCalendar_lights cal_lights; 21 - EverydayCalendar_sync cal_sync; 21 + EverydayCalendar_i2c_sync cal_sync; 22 22 int16_t brightness = 128; 23 23 24 24 ··· 94 94 } 95 95 } 96 96 97 - // Initialize LED functionality 97 + // Initialize LED hardware (but don't start Timer2 yet) 98 98 cal_lights.configure(); 99 + 100 + // Initialize I2C bus (also used by touch sensor later) 101 + Wire.begin(); 102 + 103 + // Initialize I2C sync with ESP32 104 + // Calendar will poll ESP32 every 5 seconds for current state 105 + cal_sync.configure(&cal_lights); 106 + cal_sync.begin(); 107 + 108 + // NOW start Timer2 and LED multiplexing 99 109 cal_lights.setBrightness(200); 100 110 cal_lights.begin(); 101 111 ··· 111 121 // Initialize touch functionality 112 122 cal_touch.configure(); 113 123 cal_touch.begin(); 114 - cal_lights.loadLedStatesFromMemory(); 115 124 116 - // Initialize sync with ESP32 117 - cal_sync.configure(SYNC_RX_PIN, SYNC_TX_PIN, &cal_lights); 118 - cal_sync.begin(); 125 + // Load saved state from EEPROM 126 + // (ESP32 will send authoritative state on first poll if connected) 127 + cal_lights.loadLedStatesFromMemory(); 119 128 120 129 delay(1500); 121 130 ··· 129 138 } 130 139 131 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 + 132 153 // Process incoming sync commands from ESP32 133 154 cal_sync.update(); 134 155 ··· 137 158 static const uint8_t debounceCount = 3; 138 159 static const uint16_t clearCalendarCount = 1300; // ~40 seconds. This is in units of touch sampling interval ~= 30ms. 139 160 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 + 140 168 bool touch = cal_touch.scanForTouch(); 141 169 // Handle a button press 142 170 if(touch)
+8 -6
platformio.ini
··· 29 29 ; Force deep scanning to find dependencies in included .ino files 30 30 lib_ldf_mode = deep+ 31 31 32 - ; Upload port - uncomment and adjust for your system 33 - ; upload_port = /dev/cu.usbserial-* 34 - ; monitor_port = /dev/cu.usbserial-* 32 + ; Upload port - auto-detect by device type (macOS) 33 + ; Linux users: change to /dev/ttyUSB* 34 + upload_port = /dev/cu.usbserial-* 35 + monitor_port = /dev/cu.usbserial-* 35 36 36 37 37 38 ; ============================================ ··· 65 66 board_build.partitions = default.csv 66 67 board_build.flash_mode = dio 67 68 68 - ; Upload port - uncomment and adjust for your system 69 - ; upload_port = /dev/cu.usbmodem* 70 - ; monitor_port = /dev/cu.usbmodem* 69 + ; Upload port - auto-detect by device type (macOS) 70 + ; Linux users: change to /dev/ttyACM* 71 + upload_port = /dev/cu.usbmodem* 72 + monitor_port = /dev/cu.usbmodem*
+1 -1
src/main.cpp
··· 5 5 6 6 #if defined(BUILD_ESP32SYNC) 7 7 // Include all ESP32 source files (order matters - dependencies first) 8 - #include "../firmware/esp32/GoalsGardenSync/calendar_serial.cpp" 8 + #include "../firmware/esp32/GoalsGardenSync/calendar_i2c.cpp" 9 9 #include "../firmware/esp32/GoalsGardenSync/atproto_client.cpp" 10 10 #include "../firmware/esp32/GoalsGardenSync/jetstream_client.cpp" 11 11 #include "../firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino"