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

Add ESP32 goals.garden sync firmware

Add WiFi co-processor firmware (QT Py ESP32-S2) that syncs the calendar
with goals.garden via ATProto:
- Bidirectional sync with goals.garden completions
- Real-time updates via Jetstream WebSocket
- PDS resolution via Microcosm slingshot API
- Efficient completion fetching via backlinks API
- NTP time sync with configurable timezone
- mDNS hostname support (everydaycalendar.local)
- Serial protocol for Arduino communication

Also adds PlatformIO configuration for both calendar and ESP32 builds.

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

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

+1617 -12
+22 -1
.gitignore
··· 1 - out 1 + # PlatformIO 2 + .pio/ 3 + .pioenvs/ 4 + .piolibdeps/ 5 + 6 + # VS Code 2 7 .vscode/c_cpp_properties.json 8 + .vscode/launch.json 9 + 10 + # Build outputs 11 + out/ 12 + 13 + # macOS 14 + .DS_Store 15 + 16 + # Local configuration (contains credentials) 17 + config.local.h 18 + 19 + # Compiled files 20 + *.o 21 + *.elf 22 + *.hex 23 + *.bin
-9
.vscode/arduino.json
··· 1 - { 2 - "output": "out", 3 - "configuration": "cpu=8MHzatmega328", 4 - "board": "arduino:avr:pro", 5 - "programmer": "arduino:avrispmkii", 6 - "port": "COM3", 7 - "sketch": "firmware\\sketches\\EverydayCalendar\\EverydayCalendar.ino", 8 - "prebuild": "xcopy .\\firmware\\libraries %USERPROFILE%\\Documents\\Arduino\\libraries /E /Y" 9 - }
+5
.vscode/extensions.json
··· 1 + { 2 + "recommendations": [ 3 + "platformio.platformio-ide" 4 + ] 5 + }
+6
.vscode/settings.json
··· 1 + { 2 + "files.associations": { 3 + "*.ino": "cpp" 4 + }, 5 + "C_Cpp.intelliSenseEngine": "default" 6 + }
+19
add_includes.py
··· 1 + Import("env") 2 + import os 3 + 4 + # Get the PlatformIO home directory 5 + pio_home = os.path.expanduser("~/.platformio") 6 + framework_path = os.path.join(pio_home, "packages/framework-arduinoespressif32/libraries") 7 + 8 + # Add include paths for ESP32 framework libraries 9 + include_paths = [ 10 + os.path.join(framework_path, "WiFi/src"), 11 + os.path.join(framework_path, "WiFiClientSecure/src"), 12 + os.path.join(framework_path, "WebServer/src"), 13 + os.path.join(framework_path, "HTTPClient/src"), 14 + os.path.join(framework_path, "FS/src"), 15 + os.path.join(framework_path, "Preferences/src"), 16 + ] 17 + 18 + for path in include_paths: 19 + env.Append(CPPPATH=[path])
+401
firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino
··· 1 + /* 2 + * Goals Garden Sync for Every Day Calendar 3 + * 4 + * This ESP32 firmware syncs the Every Day Calendar with goals.garden (ATProto) 5 + * 6 + * Hardware: QT Py ESP32-S2 (or similar) 7 + * Connection: Serial to Arduino via Unused I/O header (A2/A3) 8 + * 9 + * Configuration: Copy config.local.h.example to config.local.h and fill in credentials 10 + * 11 + * Features: 12 + * - Bidirectional sync with goals.garden 13 + * - Jetstream subscription for real-time updates 14 + * - NTP time synchronization 15 + */ 16 + 17 + #include <WiFi.h> 18 + #include <ESPmDNS.h> 19 + #include <HTTPClient.h> 20 + #include <WiFiClientSecure.h> 21 + #include <WebSocketsClient.h> 22 + #include <ArduinoJson.h> 23 + #include <time.h> 24 + 25 + #include "config.h" 26 + #include "calendar_serial.h" 27 + #include "atproto_client.h" 28 + #include "jetstream_client.h" 29 + 30 + // Forward declarations (needed when compiled as .cpp) 31 + void setupWiFi(); 32 + void setupNTP(); 33 + String resolvePDS(const char* identifier); 34 + void connectATProto(); 35 + void handleCalendarButton(uint8_t month, uint8_t day, bool state); 36 + void handleJetstreamEvent(JetstreamEvent& event); 37 + void performFullSync(); 38 + String findCompletionRkey(int year, int month, int day); 39 + String getISO8601Timestamp(time_t t); 40 + String getISO8601Date(int year, int month, int day); 41 + 42 + // Global objects 43 + CalendarSerial calendarSerial; 44 + ATProtoClient atproto; 45 + JetstreamClient jetstream; 46 + 47 + // Runtime state 48 + String pdsUrl; 49 + String goalUri = GOAL_URI; 50 + String goalCid; // Derived at runtime 51 + 52 + // Connection state 53 + bool wifiConnected = false; 54 + bool atprotoConnected = false; 55 + unsigned long lastSyncTime = 0; 56 + const unsigned long SYNC_INTERVAL_MS = 60000; // Full sync every minute 57 + 58 + void setup() { 59 + Serial.begin(115200); 60 + delay(1000); 61 + Serial.println(F("\n\n=== Goals Garden Sync ===")); 62 + Serial.println(F("Starting up...")); 63 + 64 + // Initialize serial communication with calendar 65 + calendarSerial.begin(); 66 + 67 + // Setup WiFi 68 + setupWiFi(); 69 + 70 + if (!wifiConnected) { 71 + Serial.println(F("WiFi failed - cannot continue")); 72 + return; 73 + } 74 + 75 + // Setup NTP with timezone 76 + setupNTP(); 77 + 78 + // Resolve PDS from identifier using slingshot 79 + pdsUrl = resolvePDS(BLUESKY_IDENTIFIER); 80 + if (pdsUrl.length() == 0) { 81 + Serial.println(F("Failed to resolve PDS - cannot continue")); 82 + return; 83 + } 84 + 85 + // Connect to ATProto 86 + connectATProto(); 87 + 88 + Serial.println(F("Setup complete!")); 89 + } 90 + 91 + void loop() { 92 + if (!wifiConnected) { 93 + delay(1000); 94 + return; 95 + } 96 + 97 + // Process serial communication with calendar 98 + calendarSerial.update(); 99 + 100 + // Check for button presses from calendar 101 + if (calendarSerial.hasButtonPress()) { 102 + CalendarButton btn = calendarSerial.getButtonPress(); 103 + handleCalendarButton(btn.month, btn.day, btn.state); 104 + } 105 + 106 + // Update Jetstream connection 107 + if (atprotoConnected) { 108 + jetstream.update(); 109 + 110 + // Process any completion changes from Jetstream 111 + while (jetstream.hasEvent()) { 112 + JetstreamEvent event = jetstream.getEvent(); 113 + handleJetstreamEvent(event); 114 + } 115 + } 116 + 117 + // Periodic full sync 118 + if (atprotoConnected && (millis() - lastSyncTime > SYNC_INTERVAL_MS)) { 119 + performFullSync(); 120 + } 121 + 122 + delay(100); // Delay to reduce CPU usage and heat 123 + } 124 + 125 + void setupWiFi() { 126 + Serial.print(F("Connecting to WiFi: ")); 127 + Serial.println(WIFI_SSID); 128 + 129 + // Clean start 130 + WiFi.disconnect(true); 131 + WiFi.mode(WIFI_OFF); 132 + delay(1000); 133 + 134 + WiFi.mode(WIFI_STA); 135 + WiFi.setAutoReconnect(true); 136 + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); 137 + 138 + int attempts = 0; 139 + while (WiFi.status() != WL_CONNECTED && attempts < 60) { 140 + delay(500); 141 + if (attempts % 10 == 9) { 142 + Serial.printf(" (status: %d)\n", WiFi.status()); 143 + } else { 144 + Serial.print("."); 145 + } 146 + attempts++; 147 + } 148 + 149 + if (WiFi.status() == WL_CONNECTED) { 150 + wifiConnected = true; 151 + Serial.println(F("\nWiFi connected!")); 152 + Serial.print(F("IP: ")); 153 + Serial.println(WiFi.localIP()); 154 + 155 + // Enable WiFi power saving 156 + WiFi.setSleep(true); 157 + 158 + // Start mDNS responder 159 + if (MDNS.begin(HOSTNAME)) { 160 + Serial.print(F("mDNS: ")); 161 + Serial.print(HOSTNAME); 162 + Serial.println(F(".local")); 163 + } 164 + } else { 165 + Serial.println(F("\nWiFi connection failed!")); 166 + Serial.println(F("Check WIFI_SSID and WIFI_PASSWORD in config.local.h")); 167 + } 168 + } 169 + 170 + void setupNTP() { 171 + if (!wifiConnected) return; 172 + 173 + Serial.println(F("Setting up NTP...")); 174 + 175 + // Configure timezone 176 + configTzTime(TIMEZONE, "pool.ntp.org", "time.nist.gov"); 177 + 178 + // Wait for time sync 179 + time_t now = time(nullptr); 180 + int attempts = 0; 181 + while (now < 1000000000 && attempts < 20) { 182 + delay(500); 183 + Serial.print("."); 184 + now = time(nullptr); 185 + attempts++; 186 + } 187 + 188 + if (now > 1000000000) { 189 + Serial.println(F("\nNTP synced!")); 190 + struct tm timeinfo; 191 + localtime_r(&now, &timeinfo); 192 + Serial.printf("Current time: %04d-%02d-%02d %02d:%02d:%02d\n", 193 + timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, 194 + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); 195 + } else { 196 + Serial.println(F("\nNTP sync failed")); 197 + } 198 + } 199 + 200 + String resolvePDS(const char* identifier) { 201 + Serial.print(F("Resolving PDS for: ")); 202 + Serial.println(identifier); 203 + 204 + HTTPClient http; 205 + WiFiClientSecure client; 206 + client.setInsecure(); // Skip certificate validation for simplicity 207 + 208 + String url = String(SLINGSHOT_API) + identifier; 209 + 210 + http.begin(client, url); 211 + int httpCode = http.GET(); 212 + 213 + String pds = ""; 214 + if (httpCode == 200) { 215 + String response = http.getString(); 216 + 217 + JsonDocument doc; 218 + DeserializationError error = deserializeJson(doc, response); 219 + 220 + if (!error && doc["pds"].is<const char*>()) { 221 + pds = doc["pds"].as<String>(); 222 + Serial.print(F("Resolved PDS: ")); 223 + Serial.println(pds); 224 + } else { 225 + Serial.println(F("Failed to parse slingshot response")); 226 + } 227 + } else { 228 + Serial.printf("Slingshot request failed: %d\n", httpCode); 229 + } 230 + 231 + http.end(); 232 + return pds; 233 + } 234 + 235 + void connectATProto() { 236 + Serial.println(F("Connecting to ATProto...")); 237 + 238 + if (atproto.createSession(pdsUrl, BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD)) { 239 + atprotoConnected = true; 240 + Serial.println(F("ATProto session created!")); 241 + Serial.print(F("DID: ")); 242 + Serial.println(atproto.getDid()); 243 + 244 + // Start Jetstream subscription and perform initial sync 245 + jetstream.begin(atproto.getDid()); 246 + performFullSync(); 247 + } else { 248 + Serial.println(F("ATProto connection failed")); 249 + Serial.println(F("Check BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD in config.local.h")); 250 + } 251 + } 252 + 253 + void handleCalendarButton(uint8_t month, uint8_t day, bool state) { 254 + if (!atprotoConnected) { 255 + Serial.println(F("Not connected, ignoring button")); 256 + return; 257 + } 258 + 259 + Serial.printf("Calendar button: month=%d, day=%d, state=%d\n", month, day, state); 260 + 261 + // Get current time info 262 + time_t now = time(nullptr); 263 + struct tm timeinfo; 264 + localtime_r(&now, &timeinfo); 265 + int currentYear = timeinfo.tm_year + 1900; 266 + int currentMonth = timeinfo.tm_mon + 1; // 1-12 267 + int currentDay = timeinfo.tm_mday; 268 + 269 + // Convert calendar coordinates to date 270 + // month is 0-11 (Jan-Dec), day is 0-30 (1st-31st) 271 + int targetMonth = month + 1; // 1-12 272 + int targetDay = day + 1; // 1-31 273 + 274 + if (state) { 275 + // Create completion record 276 + String completedAt; 277 + if (targetMonth == currentMonth && targetDay == currentDay) { 278 + // Today - use current time 279 + completedAt = getISO8601Timestamp(now); 280 + } else { 281 + // Not today - use midnight UTC for that day 282 + completedAt = getISO8601Date(currentYear, targetMonth, targetDay); 283 + } 284 + 285 + String rkey = atproto.createCompletion( 286 + goalUri, 287 + goalCid, 288 + currentYear, 289 + targetMonth, 290 + targetDay, 291 + completedAt 292 + ); 293 + 294 + if (rkey.length() > 0) { 295 + Serial.printf("Created completion: %s\n", rkey.c_str()); 296 + } else { 297 + Serial.println(F("Failed to create completion")); 298 + // Revert the LED state 299 + calendarSerial.setLED(month, day, false); 300 + } 301 + } else { 302 + // Delete completion record 303 + String rkey = findCompletionRkey(currentYear, targetMonth, targetDay); 304 + if (rkey.length() > 0) { 305 + if (atproto.deleteCompletion(rkey)) { 306 + Serial.printf("Deleted completion: %s\n", rkey.c_str()); 307 + } else { 308 + Serial.println(F("Failed to delete completion")); 309 + // Revert the LED state 310 + calendarSerial.setLED(month, day, true); 311 + } 312 + } 313 + } 314 + } 315 + 316 + void handleJetstreamEvent(JetstreamEvent& event) { 317 + if (event.collection != "garden.goals.completion") return; 318 + 319 + // Check if this completion is for our goal 320 + if (event.goalUri != goalUri) return; 321 + 322 + Serial.printf("Jetstream event: %s %s\n", 323 + event.action == JetstreamAction::Create ? "create" : "delete", 324 + event.rkey.c_str()); 325 + 326 + // Get current year 327 + time_t now = time(nullptr); 328 + struct tm timeinfo; 329 + localtime_r(&now, &timeinfo); 330 + int currentYear = timeinfo.tm_year + 1900; 331 + 332 + // Only process if it's for current year 333 + if (event.year != currentYear) return; 334 + 335 + // Convert to calendar coordinates (0-indexed) 336 + uint8_t calMonth = event.month - 1; // 0-11 337 + uint8_t calDay = event.day - 1; // 0-30 338 + 339 + if (event.action == JetstreamAction::Create) { 340 + calendarSerial.setLED(calMonth, calDay, true); 341 + } else if (event.action == JetstreamAction::Delete) { 342 + calendarSerial.setLED(calMonth, calDay, false); 343 + } 344 + } 345 + 346 + void performFullSync() { 347 + if (!atprotoConnected) return; 348 + 349 + Serial.println(F("Performing full sync...")); 350 + 351 + // Get current year 352 + time_t now = time(nullptr); 353 + struct tm timeinfo; 354 + localtime_r(&now, &timeinfo); 355 + int currentYear = timeinfo.tm_year + 1900; 356 + 357 + // Clear calendar first 358 + calendarSerial.sendCommand("CLEAR"); 359 + delay(100); 360 + 361 + // Fetch all completions for this goal 362 + std::vector<Completion> completions = atproto.getCompletions(goalUri, currentYear); 363 + 364 + // Set LEDs for each completion 365 + for (const auto& completion : completions) { 366 + if (completion.year == currentYear) { 367 + uint8_t calMonth = completion.month - 1; // 0-11 368 + uint8_t calDay = completion.day - 1; // 0-30 369 + calendarSerial.setLED(calMonth, calDay, true); 370 + delay(10); // Small delay between commands 371 + } 372 + } 373 + 374 + // Store completion rkeys for later deletion lookups 375 + atproto.cacheCompletions(completions); 376 + 377 + // Update sync timestamp to prevent immediate re-sync 378 + lastSyncTime = millis(); 379 + 380 + Serial.println(F("Full sync complete")); 381 + } 382 + 383 + String findCompletionRkey(int year, int month, int day) { 384 + return atproto.findCompletionRkey(year, month, day); 385 + } 386 + 387 + String getISO8601Timestamp(time_t t) { 388 + struct tm timeinfo; 389 + gmtime_r(&t, &timeinfo); 390 + char buf[32]; 391 + snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.000Z", 392 + timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, 393 + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); 394 + return String(buf); 395 + } 396 + 397 + String getISO8601Date(int year, int month, int day) { 398 + char buf[32]; 399 + snprintf(buf, sizeof(buf), "%04d-%02d-%02dT00:00:00.000Z", year, month, day); 400 + return String(buf); 401 + }
+412
firmware/esp32/GoalsGardenSync/atproto_client.cpp
··· 1 + #include "atproto_client.h" 2 + #include <HTTPClient.h> 3 + #include <WiFiClientSecure.h> 4 + #include <ArduinoJson.h> 5 + 6 + bool ATProtoClient::createSession(const String& pds, const String& handle, const String& appPassword) { 7 + pdsUrl = pds; 8 + 9 + // Ensure URL has no trailing slash 10 + if (pdsUrl.endsWith("/")) { 11 + pdsUrl = pdsUrl.substring(0, pdsUrl.length() - 1); 12 + } 13 + 14 + Serial.printf("Creating session at %s for %s\n", pdsUrl.c_str(), handle.c_str()); 15 + 16 + HTTPClient http; 17 + WiFiClientSecure client; 18 + client.setInsecure(); // Skip cert validation 19 + 20 + String url = pdsUrl + "/xrpc/com.atproto.server.createSession"; 21 + 22 + http.begin(client, url); 23 + http.addHeader("Content-Type", "application/json"); 24 + 25 + JsonDocument doc; 26 + doc["identifier"] = handle; 27 + doc["password"] = appPassword; 28 + 29 + String body; 30 + serializeJson(doc, body); 31 + 32 + int httpCode = http.POST(body); 33 + 34 + if (httpCode == 200) { 35 + String response = http.getString(); 36 + JsonDocument respDoc; 37 + DeserializationError error = deserializeJson(respDoc, response); 38 + 39 + if (!error) { 40 + did = respDoc["did"].as<String>(); 41 + accessJwt = respDoc["accessJwt"].as<String>(); 42 + refreshJwt = respDoc["refreshJwt"].as<String>(); 43 + 44 + Serial.println(F("Session created successfully")); 45 + http.end(); 46 + return true; 47 + } else { 48 + Serial.printf("JSON parse error: %s\n", error.c_str()); 49 + } 50 + } else { 51 + Serial.printf("HTTP error: %d\n", httpCode); 52 + Serial.println(http.getString()); 53 + } 54 + 55 + http.end(); 56 + return false; 57 + } 58 + 59 + void ATProtoClient::destroySession() { 60 + did = ""; 61 + accessJwt = ""; 62 + refreshJwt = ""; 63 + } 64 + 65 + bool ATProtoClient::isAuthenticated() { 66 + return accessJwt.length() > 0; 67 + } 68 + 69 + String ATProtoClient::getDid() { 70 + return did; 71 + } 72 + 73 + String ATProtoClient::httpGet(const String& endpoint) { 74 + if (!isAuthenticated()) { 75 + Serial.println(F("httpGet: not authenticated")); 76 + return ""; 77 + } 78 + 79 + HTTPClient http; 80 + WiFiClientSecure client; 81 + client.setInsecure(); // Skip cert validation 82 + 83 + String url = pdsUrl + endpoint; 84 + Serial.printf("GET %s\n", url.c_str()); 85 + 86 + http.begin(client, url); 87 + http.addHeader("Authorization", "Bearer " + accessJwt); 88 + 89 + int httpCode = http.GET(); 90 + Serial.printf("Response: %d\n", httpCode); 91 + 92 + String response = ""; 93 + if (httpCode == 200) { 94 + response = http.getString(); 95 + } else if (httpCode == 401) { 96 + // Token expired, try to refresh 97 + if (refreshSession()) { 98 + http.end(); 99 + return httpGet(endpoint); 100 + } 101 + } else { 102 + Serial.printf("GET failed: %d\n", httpCode); 103 + if (httpCode > 0) { 104 + Serial.println(http.getString()); 105 + } 106 + } 107 + 108 + http.end(); 109 + return response; 110 + } 111 + 112 + String ATProtoClient::httpPost(const String& endpoint, const String& body) { 113 + if (!isAuthenticated()) { 114 + Serial.println(F("httpPost: not authenticated")); 115 + return ""; 116 + } 117 + 118 + HTTPClient http; 119 + WiFiClientSecure client; 120 + client.setInsecure(); // Skip cert validation 121 + 122 + String url = pdsUrl + endpoint; 123 + 124 + http.begin(client, url); 125 + http.addHeader("Content-Type", "application/json"); 126 + http.addHeader("Authorization", "Bearer " + accessJwt); 127 + 128 + int httpCode = http.POST(body); 129 + 130 + String response = ""; 131 + if (httpCode == 200) { 132 + response = http.getString(); 133 + } else if (httpCode == 401) { 134 + // Token expired, try to refresh 135 + if (refreshSession()) { 136 + http.end(); 137 + return httpPost(endpoint, body); 138 + } 139 + } else { 140 + Serial.printf("POST %s failed: %d\n", endpoint.c_str(), httpCode); 141 + if (httpCode > 0) { 142 + Serial.println(http.getString()); 143 + } 144 + } 145 + 146 + http.end(); 147 + return response; 148 + } 149 + 150 + bool ATProtoClient::refreshSession() { 151 + Serial.println(F("Refreshing session...")); 152 + 153 + HTTPClient http; 154 + WiFiClientSecure client; 155 + client.setInsecure(); // Skip cert validation 156 + 157 + String url = pdsUrl + "/xrpc/com.atproto.server.refreshSession"; 158 + 159 + http.begin(client, url); 160 + http.addHeader("Authorization", "Bearer " + refreshJwt); 161 + 162 + int httpCode = http.POST(""); 163 + 164 + if (httpCode == 200) { 165 + String response = http.getString(); 166 + JsonDocument doc; 167 + DeserializationError error = deserializeJson(doc, response); 168 + 169 + if (!error) { 170 + accessJwt = doc["accessJwt"].as<String>(); 171 + refreshJwt = doc["refreshJwt"].as<String>(); 172 + Serial.println(F("Session refreshed")); 173 + http.end(); 174 + return true; 175 + } 176 + } 177 + 178 + Serial.println(F("Session refresh failed")); 179 + http.end(); 180 + return false; 181 + } 182 + 183 + std::vector<Goal> ATProtoClient::getGoals(int year) { 184 + std::vector<Goal> goals; 185 + 186 + String endpoint = "/xrpc/com.atproto.repo.listRecords?repo=" + did + 187 + "&collection=garden.goals.goal&limit=100"; 188 + 189 + String response = httpGet(endpoint); 190 + if (response.length() == 0) return goals; 191 + 192 + JsonDocument doc; 193 + DeserializationError error = deserializeJson(doc, response); 194 + if (error) { 195 + Serial.printf("JSON parse error: %s\n", error.c_str()); 196 + return goals; 197 + } 198 + 199 + JsonArray records = doc["records"].as<JsonArray>(); 200 + for (JsonObject record : records) { 201 + JsonObject value = record["value"].as<JsonObject>(); 202 + 203 + // Filter by year if specified 204 + int goalYear = value["year"] | 0; 205 + if (year > 0 && goalYear != year) continue; 206 + 207 + Goal goal; 208 + goal.uri = record["uri"].as<String>(); 209 + goal.cid = record["cid"].as<String>(); 210 + goal.name = value["name"].as<String>(); 211 + goal.year = goalYear; 212 + 213 + goals.push_back(goal); 214 + } 215 + 216 + Serial.printf("Found %d goals for year %d\n", goals.size(), year); 217 + return goals; 218 + } 219 + 220 + std::vector<Completion> ATProtoClient::getCompletions(const String& goalUri, int year) { 221 + std::vector<Completion> completions; 222 + 223 + // Use Microcosm backlinks API to get completions for this specific goal 224 + // This is much more efficient than listing all completions and filtering 225 + String cursor = ""; 226 + bool hasMore = true; 227 + 228 + // URL-encode the goal URI for the query parameter 229 + String encodedGoalUri = goalUri; 230 + encodedGoalUri.replace(":", "%3A"); 231 + encodedGoalUri.replace("/", "%2F"); 232 + 233 + while (hasMore) { 234 + String url = "https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks" 235 + "?subject=" + encodedGoalUri + 236 + "&source=garden.goals.completion%3Agoal.uri&limit=100"; 237 + if (cursor.length() > 0) { 238 + url += "&cursor=" + cursor; 239 + } 240 + 241 + Serial.printf("GET %s\n", url.c_str()); 242 + 243 + HTTPClient http; 244 + WiFiClientSecure client; 245 + client.setInsecure(); 246 + http.begin(client, url); 247 + 248 + int httpCode = http.GET(); 249 + Serial.printf("Response: %d\n", httpCode); 250 + 251 + if (httpCode != 200) { 252 + Serial.printf("Backlinks request failed: %d\n", httpCode); 253 + http.end(); 254 + break; 255 + } 256 + 257 + String response = http.getString(); 258 + http.end(); 259 + 260 + JsonDocument doc; 261 + DeserializationError error = deserializeJson(doc, response); 262 + if (error) { 263 + Serial.printf("JSON parse error: %s\n", error.c_str()); 264 + break; 265 + } 266 + 267 + JsonArray records = doc["records"].as<JsonArray>(); 268 + for (JsonObject record : records) { 269 + // Each record has did, collection, rkey directly 270 + String repo = record["did"].as<String>(); 271 + String rkey = record["rkey"].as<String>(); 272 + 273 + // Fetch the full record to get year/month/day details 274 + String recordUrl = pdsUrl + "/xrpc/com.atproto.repo.getRecord" 275 + "?repo=" + repo + 276 + "&collection=garden.goals.completion" 277 + "&rkey=" + rkey; 278 + 279 + HTTPClient recordHttp; 280 + WiFiClientSecure recordClient; 281 + recordClient.setInsecure(); 282 + recordHttp.begin(recordClient, recordUrl); 283 + recordHttp.addHeader("Authorization", "Bearer " + accessJwt); 284 + 285 + int recordCode = recordHttp.GET(); 286 + if (recordCode == 200) { 287 + String recordResponse = recordHttp.getString(); 288 + JsonDocument recordDoc; 289 + if (!deserializeJson(recordDoc, recordResponse)) { 290 + JsonObject value = recordDoc["value"].as<JsonObject>(); 291 + 292 + int compYear = value["year"] | 0; 293 + // Filter by year if specified 294 + if (year > 0 && compYear != year) { 295 + recordHttp.end(); 296 + continue; 297 + } 298 + 299 + Completion comp; 300 + comp.rkey = rkey; 301 + comp.year = compYear; 302 + comp.month = value["month"] | 0; 303 + comp.day = value["day"] | 0; 304 + comp.completedAt = value["completedAt"].as<String>(); 305 + comp.goalUri = goalUri; 306 + 307 + completions.push_back(comp); 308 + } 309 + } 310 + recordHttp.end(); 311 + } 312 + 313 + // Check for pagination - cursor is null when no more pages 314 + if (doc["cursor"].is<const char*>()) { 315 + cursor = doc["cursor"].as<String>(); 316 + hasMore = cursor.length() > 0 && cursor != "null"; 317 + } else { 318 + hasMore = false; 319 + } 320 + } 321 + 322 + Serial.printf("Found %d completions for year %d\n", completions.size(), year); 323 + return completions; 324 + } 325 + 326 + String ATProtoClient::createCompletion(const String& goalUri, const String& goalCid, 327 + int year, int month, int day, 328 + const String& completedAt) { 329 + JsonDocument doc; 330 + doc["repo"] = did; 331 + doc["collection"] = "garden.goals.completion"; 332 + 333 + JsonObject record = doc["record"].to<JsonObject>(); 334 + record["$type"] = "garden.goals.completion"; 335 + record["year"] = year; 336 + record["month"] = month; 337 + record["day"] = day; 338 + record["completedAt"] = completedAt; 339 + 340 + JsonObject goal = record["goal"].to<JsonObject>(); 341 + goal["uri"] = goalUri; 342 + goal["cid"] = goalCid; 343 + 344 + String body; 345 + serializeJson(doc, body); 346 + 347 + String response = httpPost("/xrpc/com.atproto.repo.createRecord", body); 348 + if (response.length() == 0) return ""; 349 + 350 + JsonDocument respDoc; 351 + DeserializationError error = deserializeJson(respDoc, response); 352 + if (error) return ""; 353 + 354 + String uri = respDoc["uri"].as<String>(); 355 + // Extract rkey from URI 356 + int lastSlash = uri.lastIndexOf('/'); 357 + if (lastSlash >= 0) { 358 + String rkey = uri.substring(lastSlash + 1); 359 + 360 + // Add to cache 361 + Completion comp; 362 + comp.rkey = rkey; 363 + comp.year = year; 364 + comp.month = month; 365 + comp.day = day; 366 + comp.completedAt = completedAt; 367 + comp.goalUri = goalUri; 368 + completionCache.push_back(comp); 369 + 370 + return rkey; 371 + } 372 + 373 + return ""; 374 + } 375 + 376 + bool ATProtoClient::deleteCompletion(const String& rkey) { 377 + JsonDocument doc; 378 + doc["repo"] = did; 379 + doc["collection"] = "garden.goals.completion"; 380 + doc["rkey"] = rkey; 381 + 382 + String body; 383 + serializeJson(doc, body); 384 + 385 + String response = httpPost("/xrpc/com.atproto.repo.deleteRecord", body); 386 + 387 + if (response.length() > 0) { 388 + // Remove from cache 389 + for (auto it = completionCache.begin(); it != completionCache.end(); ++it) { 390 + if (it->rkey == rkey) { 391 + completionCache.erase(it); 392 + break; 393 + } 394 + } 395 + return true; 396 + } 397 + 398 + return false; 399 + } 400 + 401 + void ATProtoClient::cacheCompletions(const std::vector<Completion>& completions) { 402 + completionCache = completions; 403 + } 404 + 405 + String ATProtoClient::findCompletionRkey(int year, int month, int day) { 406 + for (const auto& comp : completionCache) { 407 + if (comp.year == year && comp.month == month && comp.day == day) { 408 + return comp.rkey; 409 + } 410 + } 411 + return ""; 412 + }
+60
firmware/esp32/GoalsGardenSync/atproto_client.h
··· 1 + #ifndef ATPROTO_CLIENT_H 2 + #define ATPROTO_CLIENT_H 3 + 4 + #include <Arduino.h> 5 + #include <vector> 6 + 7 + struct Completion { 8 + String rkey; 9 + int year; 10 + int month; 11 + int day; 12 + String completedAt; 13 + String goalUri; 14 + }; 15 + 16 + struct Goal { 17 + String uri; 18 + String cid; 19 + String name; 20 + int year; 21 + }; 22 + 23 + class ATProtoClient { 24 + public: 25 + // Session management 26 + bool createSession(const String& pds, const String& handle, const String& appPassword); 27 + void destroySession(); 28 + bool isAuthenticated(); 29 + String getDid(); 30 + 31 + // Goals 32 + std::vector<Goal> getGoals(int year); 33 + 34 + // Completions 35 + std::vector<Completion> getCompletions(const String& goalUri, int year); 36 + String createCompletion(const String& goalUri, const String& goalCid, 37 + int year, int month, int day, const String& completedAt); 38 + bool deleteCompletion(const String& rkey); 39 + 40 + // Cache for completion lookups 41 + void cacheCompletions(const std::vector<Completion>& completions); 42 + String findCompletionRkey(int year, int month, int day); 43 + 44 + private: 45 + String pdsUrl; 46 + String did; 47 + String accessJwt; 48 + String refreshJwt; 49 + 50 + std::vector<Completion> completionCache; 51 + 52 + // HTTP helpers 53 + String httpGet(const String& endpoint); 54 + String httpPost(const String& endpoint, const String& body); 55 + 56 + // Refresh token if needed 57 + bool refreshSession(); 58 + }; 59 + 60 + #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
+26
firmware/esp32/GoalsGardenSync/config.h
··· 1 + #ifndef CONFIG_H 2 + #define CONFIG_H 3 + 4 + #include <Arduino.h> 5 + 6 + // Include local configuration (credentials) 7 + // Copy config.local.h.example to config.local.h and fill in your values 8 + #if __has_include("config.local.h") 9 + #include "config.local.h" 10 + #else 11 + #error "config.local.h not found. Copy config.local.h.example to config.local.h and fill in your credentials." 12 + #endif 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 19 + 20 + // Slingshot API for resolving PDS from identifier 21 + #define SLINGSHOT_API "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 22 + 23 + // Days in each month (non-leap year) 24 + const uint8_t DAYS_IN_MONTH[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; 25 + 26 + #endif
+33
firmware/esp32/GoalsGardenSync/config.local.h.example
··· 1 + // Copy this file to config.local.h and fill in your credentials 2 + // config.local.h is gitignored and will not be committed 3 + 4 + #ifndef CONFIG_LOCAL_H 5 + #define CONFIG_LOCAL_H 6 + 7 + // WiFi credentials 8 + #define WIFI_SSID "your-wifi-ssid" 9 + #define WIFI_PASSWORD "your-wifi-password" 10 + 11 + // Bluesky/ATProto credentials 12 + // Your handle (e.g., "alice.bsky.social") or DID (e.g., "did:plc:xyz...") 13 + #define BLUESKY_IDENTIFIER "your-handle.bsky.social" 14 + // Create an App Password at: https://bsky.app/settings/app-passwords 15 + #define BLUESKY_APP_PASSWORD "xxxx-xxxx-xxxx-xxxx" 16 + 17 + // Goal URI from goals.garden 18 + // Format: at://did:plc:xxxx/garden.goals.goal/xxxx 19 + #define GOAL_URI "at://did:plc:your-did/garden.goals.goal/your-goal-rkey" 20 + 21 + // Network hostname (optional) - accessible as <hostname>.local 22 + #define HOSTNAME "everydaycalendar" 23 + 24 + // Timezone (optional) - POSIX timezone string 25 + // See: https://github.com/nayarsystems/posix_tz_db/blob/master/zones.csv 26 + // Examples: 27 + // "GMT0BST,M3.5.0/1,M10.5.0" - UK (London) 28 + // "EST5EDT,M3.2.0,M11.1.0" - US Eastern 29 + // "PST8PDT,M3.2.0,M11.1.0" - US Pacific 30 + // "CET-1CEST,M3.5.0,M10.5.0/3" - Central Europe 31 + #define TIMEZONE "GMT0BST,M3.5.0/1,M10.5.0" 32 + 33 + #endif
+147
firmware/esp32/GoalsGardenSync/jetstream_client.cpp
··· 1 + #include "jetstream_client.h" 2 + #include <ArduinoJson.h> 3 + 4 + // Jetstream public endpoint 5 + #define JETSTREAM_HOST "jetstream2.us-east.bsky.network" 6 + #define JETSTREAM_PORT 443 7 + 8 + // Static instance for callback 9 + JetstreamClient* JetstreamClient::instance = nullptr; 10 + 11 + void JetstreamClient::begin(const String& did) { 12 + subscribedDid = did; 13 + instance = this; 14 + 15 + Serial.println(F("Connecting to Jetstream...")); 16 + 17 + // Build subscription URL with filters 18 + // We want to subscribe to garden.goals.completion records for our DID 19 + String path = "/subscribe?wantedCollections=garden.goals.completion"; 20 + path += "&wantedDids=" + did; 21 + 22 + webSocket.beginSSL(JETSTREAM_HOST, JETSTREAM_PORT, path.c_str()); 23 + webSocket.onEvent(webSocketEvent); 24 + webSocket.setReconnectInterval(5000); 25 + 26 + Serial.printf("Jetstream subscribing to %s\n", did.c_str()); 27 + } 28 + 29 + void JetstreamClient::update() { 30 + webSocket.loop(); 31 + } 32 + 33 + void JetstreamClient::disconnect() { 34 + webSocket.disconnect(); 35 + connected = false; 36 + } 37 + 38 + bool JetstreamClient::isConnected() { 39 + return connected; 40 + } 41 + 42 + bool JetstreamClient::hasEvent() { 43 + return !eventQueue.empty(); 44 + } 45 + 46 + JetstreamEvent JetstreamClient::getEvent() { 47 + JetstreamEvent event = eventQueue.front(); 48 + eventQueue.pop(); 49 + return event; 50 + } 51 + 52 + void JetstreamClient::webSocketEvent(WStype_t type, uint8_t* payload, size_t length) { 53 + if (instance == nullptr) return; 54 + 55 + switch (type) { 56 + case WStype_DISCONNECTED: 57 + Serial.println(F("Jetstream disconnected")); 58 + instance->connected = false; 59 + break; 60 + 61 + case WStype_CONNECTED: 62 + Serial.println(F("Jetstream connected!")); 63 + instance->connected = true; 64 + break; 65 + 66 + case WStype_TEXT: { 67 + String message = String((char*)payload); 68 + instance->handleMessage(message); 69 + break; 70 + } 71 + 72 + case WStype_ERROR: 73 + Serial.println(F("Jetstream error")); 74 + break; 75 + 76 + default: 77 + break; 78 + } 79 + } 80 + 81 + void JetstreamClient::handleMessage(const String& message) { 82 + // Parse Jetstream message 83 + // Format: https://docs.bsky.app/docs/advanced-guides/jetstream 84 + /* 85 + { 86 + "did": "did:plc:...", 87 + "time_us": 1234567890123456, 88 + "kind": "commit", 89 + "commit": { 90 + "rev": "...", 91 + "operation": "create" | "update" | "delete", 92 + "collection": "garden.goals.completion", 93 + "rkey": "...", 94 + "record": { ... } // Only for create/update 95 + } 96 + } 97 + */ 98 + 99 + JsonDocument doc; 100 + DeserializationError error = deserializeJson(doc, message); 101 + if (error) { 102 + // Not a JSON message, might be a ping 103 + return; 104 + } 105 + 106 + // Check if this is a commit event 107 + String kind = doc["kind"].as<String>(); 108 + if (kind != "commit") return; 109 + 110 + JsonObject commit = doc["commit"].as<JsonObject>(); 111 + String collection = commit["collection"].as<String>(); 112 + 113 + // Only process completion records 114 + if (collection != "garden.goals.completion") return; 115 + 116 + String operation = commit["operation"].as<String>(); 117 + String rkey = commit["rkey"].as<String>(); 118 + 119 + JetstreamEvent event; 120 + event.collection = collection; 121 + event.rkey = rkey; 122 + 123 + if (operation == "create") { 124 + event.action = JetstreamAction::Create; 125 + 126 + // Parse the record for details 127 + JsonObject record = commit["record"].as<JsonObject>(); 128 + event.year = record["year"] | 0; 129 + event.month = record["month"] | 0; 130 + event.day = record["day"] | 0; 131 + event.goalUri = record["goal"]["uri"].as<String>(); 132 + 133 + } else if (operation == "delete") { 134 + event.action = JetstreamAction::Delete; 135 + // For deletes, we need to look up the details from our cache 136 + // The main loop will handle this 137 + 138 + } else { 139 + event.action = JetstreamAction::Unknown; 140 + return; // Ignore updates for now 141 + } 142 + 143 + Serial.printf("Jetstream event: %s %s/%s\n", 144 + operation.c_str(), collection.c_str(), rkey.c_str()); 145 + 146 + eventQueue.push(event); 147 + }
+47
firmware/esp32/GoalsGardenSync/jetstream_client.h
··· 1 + #ifndef JETSTREAM_CLIENT_H 2 + #define JETSTREAM_CLIENT_H 3 + 4 + #include <Arduino.h> 5 + #include <WebSocketsClient.h> 6 + #include <queue> 7 + 8 + enum class JetstreamAction { 9 + Create, 10 + Delete, 11 + Unknown 12 + }; 13 + 14 + struct JetstreamEvent { 15 + JetstreamAction action; 16 + String collection; 17 + String rkey; 18 + String goalUri; 19 + int year; 20 + int month; 21 + int day; 22 + }; 23 + 24 + class JetstreamClient { 25 + public: 26 + void begin(const String& did); 27 + void update(); 28 + void disconnect(); 29 + 30 + bool isConnected(); 31 + bool hasEvent(); 32 + JetstreamEvent getEvent(); 33 + 34 + private: 35 + WebSocketsClient webSocket; 36 + String subscribedDid; 37 + bool connected = false; 38 + std::queue<JetstreamEvent> eventQueue; 39 + 40 + static void webSocketEvent(WStype_t type, uint8_t* payload, size_t length); 41 + void handleMessage(const String& message); 42 + 43 + // Static instance pointer for callback 44 + static JetstreamClient* instance; 45 + }; 46 + 47 + #endif
+10
firmware/libraries/EverydayCalendar/EverydayCalendar_lights.cpp
··· 156 156 } 157 157 } 158 158 159 + uint32_t EverydayCalendar_lights::getLedState(uint8_t month){ 160 + if (month > 11) return 0; 161 + return ledValues[month]; 162 + } 163 + 164 + bool EverydayCalendar_lights::isLedOn(uint8_t month, uint8_t day){ 165 + if (month > 11 || day > 30) return false; 166 + return (ledValues[month] & ((uint32_t)1 << day)) != 0; 167 + } 168 + 159 169 160 170 // Code to drive the LED multiplexing. 161 171 // This code is called at a very fast period, activating each LED column one by one
+6 -1
firmware/libraries/EverydayCalendar/EverydayCalendar_lights.h
··· 17 17 void setBrightness(uint8_t brightness); 18 18 19 19 void setOverrideLED(uint8_t month, uint8_t day, bool enable); 20 - void EverydayCalendar_lights::clearOverrideLEDs(); 20 + void clearOverrideLEDs(); 21 + 22 + // Get LED state for a month (bitmask of days 0-30) 23 + uint32_t getLedState(uint8_t month); 24 + // Check if a specific LED is on 25 + bool isLedOn(uint8_t month, uint8_t day); 21 26 }; 22 27 23 28 #endif
+120
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 6 + #define COMMAND_TIMEOUT_MS 30000 // Consider disconnected after 30s of no commands 7 + 8 + void EverydayCalendar_sync::configure(uint8_t rxPin, uint8_t txPin, EverydayCalendar_lights* lights) { 9 + _serial = new SoftwareSerial(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")); 18 + } 19 + 20 + void EverydayCalendar_sync::update() { 21 + while (_serial->available()) { 22 + char c = _serial->read(); 23 + 24 + if (c == '\n' || c == '\r') { 25 + if (_cmdIndex > 0) { 26 + _cmdBuffer[_cmdIndex] = '\0'; 27 + processCommand(); 28 + _cmdIndex = 0; 29 + } 30 + } else if (_cmdIndex < sizeof(_cmdBuffer) - 1) { 31 + _cmdBuffer[_cmdIndex++] = c; 32 + } 33 + } 34 + } 35 + 36 + void EverydayCalendar_sync::processCommand() { 37 + _lastCommandTime = millis(); 38 + 39 + Serial.print(F("Sync cmd: ")); 40 + Serial.println(_cmdBuffer); 41 + 42 + // Parse command 43 + if (strncmp(_cmdBuffer, "SET,", 4) == 0) { 44 + handleSet(_cmdBuffer + 4); 45 + } else if (strcmp(_cmdBuffer, "SYNC") == 0) { 46 + handleSync(); 47 + } else if (strcmp(_cmdBuffer, "CLEAR") == 0) { 48 + handleClear(); 49 + } else if (strcmp(_cmdBuffer, "PING") == 0) { 50 + _serial->println(F("PONG")); 51 + } else { 52 + _serial->print(F("ERR,Unknown command: ")); 53 + _serial->println(_cmdBuffer); 54 + } 55 + } 56 + 57 + void EverydayCalendar_sync::handleSet(const char* args) { 58 + // Parse: month,day,on 59 + int month, day, on; 60 + if (sscanf(args, "%d,%d,%d", &month, &day, &on) == 3) { 61 + if (month >= 0 && month < 12 && day >= 0 && day < 31) { 62 + _lights->setLED(month, day, on != 0); 63 + _lights->saveLedStatesToMemory(); 64 + _serial->println(F("OK")); 65 + 66 + Serial.print(F("Sync: SET ")); 67 + Serial.print(month); 68 + Serial.print(F(",")); 69 + Serial.print(day); 70 + Serial.print(F(" = ")); 71 + Serial.println(on); 72 + } else { 73 + _serial->println(F("ERR,Invalid month/day")); 74 + } 75 + } else { 76 + _serial->println(F("ERR,Parse error")); 77 + } 78 + } 79 + 80 + void EverydayCalendar_sync::handleSync() { 81 + Serial.println(F("Sync: full state requested")); 82 + sendState(); 83 + } 84 + 85 + void EverydayCalendar_sync::handleClear() { 86 + _lights->clearAllLEDs(); 87 + _lights->saveLedStatesToMemory(); 88 + _serial->println(F("OK")); 89 + Serial.println(F("Sync: cleared all LEDs")); 90 + } 91 + 92 + void EverydayCalendar_sync::sendState() { 93 + _serial->print(F("STATE")); 94 + for (uint8_t month = 0; month < 12; month++) { 95 + _serial->print(','); 96 + _serial->print(_lights->getLedState(month), HEX); 97 + } 98 + _serial->println(); 99 + } 100 + 101 + void EverydayCalendar_sync::sendButtonPress(uint8_t month, uint8_t day, bool newState) { 102 + _serial->print(F("BTN,")); 103 + _serial->print(month); 104 + _serial->print(','); 105 + _serial->print(day); 106 + _serial->print(','); 107 + _serial->println(newState ? 1 : 0); 108 + 109 + Serial.print(F("Sync: sent BTN ")); 110 + Serial.print(month); 111 + Serial.print(','); 112 + Serial.print(day); 113 + Serial.print(','); 114 + Serial.println(newState); 115 + } 116 + 117 + bool EverydayCalendar_sync::isConnected() { 118 + if (_lastCommandTime == 0) return false; 119 + return (millis() - _lastCommandTime) < COMMAND_TIMEOUT_MS; 120 + }
+53
firmware/libraries/EverydayCalendar/EverydayCalendar_sync.h
··· 1 + #ifndef __EVERYDAYCALENDAR_SYNC_H 2 + #define __EVERYDAYCALENDAR_SYNC_H 3 + 4 + #include <stdint.h> 5 + #include <SoftwareSerial.h> 6 + 7 + // Forward declarations 8 + class EverydayCalendar_lights; 9 + 10 + // Serial protocol for ESP32 sync 11 + // Commands from ESP32 to Arduino: 12 + // SET,<month>,<day>,<on>\n - Set LED state (month 0-11, day 0-30, on 0/1) 13 + // SYNC\n - Request full state dump 14 + // CLEAR\n - Clear all LEDs 15 + // 16 + // Commands from Arduino to ESP32: 17 + // BTN,<month>,<day>,<on>\n - Button pressed, LED toggled to state (month 0-11, day 0-30) 18 + // STATE,<m0>,<m1>,...<m11>\n - Full state: 12 hex values (uint32), one per month 19 + // OK\n - Acknowledgment 20 + // ERR,<msg>\n - Error message 21 + 22 + class EverydayCalendar_sync 23 + { 24 + public: 25 + // Initialize with pins for SoftwareSerial (A2=16, A3=17 on ATmega328P) 26 + void configure(uint8_t rxPin, uint8_t txPin, EverydayCalendar_lights* lights); 27 + void begin(); 28 + 29 + // Call in loop() to process incoming commands 30 + void update(); 31 + 32 + // Call when a button is pressed to notify ESP32 33 + void sendButtonPress(uint8_t month, uint8_t day, bool newState); 34 + 35 + // Check if sync is connected (received any valid command recently) 36 + bool isConnected(); 37 + 38 + private: 39 + SoftwareSerial* _serial; 40 + EverydayCalendar_lights* _lights; 41 + 42 + char _cmdBuffer[64]; 43 + uint8_t _cmdIndex; 44 + unsigned long _lastCommandTime; 45 + 46 + void processCommand(); 47 + void handleSet(const char* args); 48 + void handleSync(); 49 + void handleClear(); 50 + void sendState(); 51 + }; 52 + 53 + #endif
+7
firmware/libraries/EverydayCalendar/library.json
··· 1 + { 2 + "name": "EverydayCalendar", 3 + "version": "1.0.0", 4 + "description": "Library for Simone Giertz's Every Day Calendar", 5 + "frameworks": "arduino", 6 + "platforms": ["atmelavr", "espressif32"] 7 + }
+23 -1
firmware/sketches/EverydayCalendar/EverydayCalendar.ino
··· 1 1 #include <EverydayCalendar_lights.h> 2 2 #include <EverydayCalendar_touch.h> 3 + #include <EverydayCalendar_sync.h> 4 + 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 3 8 4 9 typedef struct { 5 10 int8_t x; 6 11 int8_t y; 7 12 } Point; 8 13 14 + // Forward declarations (needed for PlatformIO compilation) 15 + void honeyDrip(); 16 + void clearAnimation(); 17 + void doBurst(Point p); 18 + 9 19 EverydayCalendar_touch cal_touch; 10 20 EverydayCalendar_lights cal_lights; 21 + EverydayCalendar_sync cal_sync; 11 22 int16_t brightness = 128; 12 23 13 24 ··· 101 112 cal_touch.configure(); 102 113 cal_touch.begin(); 103 114 cal_lights.loadLedStatesFromMemory(); 115 + 116 + // Initialize sync with ESP32 117 + cal_sync.configure(SYNC_RX_PIN, SYNC_TX_PIN, &cal_lights); 118 + cal_sync.begin(); 119 + 104 120 delay(1500); 105 121 106 122 // Fade in ··· 113 129 } 114 130 115 131 void loop() { 132 + // Process incoming sync commands from ESP32 133 + cal_sync.update(); 134 + 116 135 static Point previouslyHeldButton = {(char)0xFF, (char)0xFF}; // 0xFF and 0xFF if no button is held 117 136 static uint16_t touchCount = 1; 118 137 static const uint8_t debounceCount = 3; 119 - static const uint16_t clearCalendarCount = 1300; // ~40 seconds. This is in units of touch sampling interval ~= 30ms. 138 + static const uint16_t clearCalendarCount = 1300; // ~40 seconds. This is in units of touch sampling interval ~= 30ms. 120 139 Point buttonPressed = {(char)0xFF, (char)0xFF}; 121 140 bool touch = cal_touch.scanForTouch(); 122 141 // Handle a button press ··· 153 172 Serial.print(cal_touch.x); 154 173 Serial.print("\ty: "); 155 174 Serial.println(cal_touch.y); 175 + 176 + // Notify ESP32 of the button press 177 + cal_sync.sendButtonPress((uint8_t)cal_touch.x, (uint8_t)cal_touch.y, on); 156 178 157 179 // Burst if toggled on 158 180 if (on) {
+65
platformio.ini
··· 1 + ; PlatformIO Configuration for Every Day Calendar 2 + ; 3 + ; Usage: 4 + ; Build calendar: pio run -e calendar 5 + ; Upload calendar: pio run -e calendar -t upload 6 + ; Build ESP32: pio run -e esp32sync 7 + ; Upload ESP32: pio run -e esp32sync -t upload 8 + ; Monitor serial: pio device monitor -e <environment> 9 + 10 + [platformio] 11 + default_envs = calendar 12 + src_dir = src 13 + 14 + ; ============================================ 15 + ; Every Day Calendar - ATmega328P (Arduino Pro Mini 3.3V 8MHz) 16 + ; ============================================ 17 + [env:calendar] 18 + platform = atmelavr 19 + board = pro8MHzatmega328 20 + framework = arduino 21 + upload_speed = 57600 22 + monitor_speed = 9600 23 + 24 + build_flags = -DBUILD_CALENDAR 25 + 26 + ; Libraries location 27 + lib_extra_dirs = firmware/libraries 28 + 29 + ; Force deep scanning to find dependencies in included .ino files 30 + lib_ldf_mode = deep+ 31 + 32 + ; Upload port - uncomment and adjust for your system 33 + ; upload_port = /dev/cu.usbserial-* 34 + ; monitor_port = /dev/cu.usbserial-* 35 + 36 + 37 + ; ============================================ 38 + ; Goals Garden Sync - ESP32-S2 (Adafruit QT Py) 39 + ; ============================================ 40 + [env:esp32sync] 41 + platform = espressif32 42 + board = adafruit_qtpy_esp32s2 43 + framework = arduino 44 + monitor_speed = 115200 45 + upload_speed = 921600 46 + 47 + build_flags = -DBUILD_ESP32SYNC 48 + 49 + ; Script to add framework library include paths for external lib compilation 50 + extra_scripts = pre:add_includes.py 51 + 52 + ; Force deep scanning to find dependencies in included .ino files 53 + lib_ldf_mode = deep+ 54 + 55 + lib_deps = 56 + bblanchon/ArduinoJson@^7.0.0 57 + links2004/WebSockets@^2.4.0 58 + 59 + ; Board-specific settings 60 + board_build.partitions = default.csv 61 + board_build.flash_mode = qio 62 + 63 + ; Upload port - uncomment and adjust for your system 64 + ; upload_port = /dev/cu.usbmodem* 65 + ; monitor_port = /dev/cu.usbmodem*
+16
src/main.cpp
··· 1 + // PlatformIO main wrapper 2 + // This file includes the appropriate sketch based on the build environment 3 + 4 + #include <Arduino.h> 5 + 6 + #if defined(BUILD_ESP32SYNC) 7 + // Include all ESP32 source files (order matters - dependencies first) 8 + #include "../firmware/esp32/GoalsGardenSync/calendar_serial.cpp" 9 + #include "../firmware/esp32/GoalsGardenSync/atproto_client.cpp" 10 + #include "../firmware/esp32/GoalsGardenSync/jetstream_client.cpp" 11 + #include "../firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino" 12 + #elif defined(BUILD_CALENDAR) 13 + #include "../firmware/sketches/EverydayCalendar/EverydayCalendar.ino" 14 + #else 15 + #error "No build target defined. Use -e calendar or -e esp32sync" 16 + #endif