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.

Make Jetstream and Constellation URLs configurable

- Change JETSTREAM_HOST/PORT to single JETSTREAM_URL with scheme
(wss:// implies port 443, ws:// implies port 80)
- Change CONSTELLATION_API to CONSTELLATION_URL (base URL only)
- Parse URL scheme to determine SSL vs plain WebSocket connection
- Update config.local.h.example with new URL format

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

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

+285 -138
+245 -123
firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino
··· 33 33 // Forward declarations 34 34 void setupWiFi(); 35 35 void setupNTP(); 36 - String resolvePDS(const char* identifier); 36 + String resolvePDS(const char *identifier); 37 37 void connectATProto(); 38 38 void loadOrSelectGoal(); 39 39 void handleFirstBoot(); 40 - bool calendarStateHasLeds(uint32_t* monthStates); 41 - void uploadCalendarStateAsCompletions(uint32_t* monthStates); 40 + bool calendarStateHasLeds(uint32_t *monthStates); 41 + void uploadCalendarStateAsCompletions(uint32_t *monthStates); 42 42 void refreshGoals(); 43 43 void handleGoalSwitch(); 44 44 void handleCalendarButton(uint8_t month, uint8_t day, bool state); 45 - void handleJetstreamEvent(JetstreamEvent& event); 45 + void handleJetstreamEvent(JetstreamEvent &event); 46 46 void performFullSync(); 47 47 String findCompletionRkey(int year, int month, int day); 48 48 String getISO8601Timestamp(time_t t); ··· 65 65 bool wifiConnected = false; 66 66 bool atprotoConnected = false; 67 67 bool wasJetstreamConnected = false; 68 - bool wasOnline = false; // Track online status for calendar animation feedback 68 + bool wasOnline = false; // Track online status for calendar animation feedback 69 69 70 70 // Timing for background refresh 71 71 unsigned long lastGoalsRefresh = 0; 72 72 73 - void setup() { 73 + void setup() 74 + { 74 75 Serial.begin(115200); 75 76 delay(1000); 76 77 Serial.println(F("\n\n=== Goals Garden Sync ===")); ··· 85 86 // Setup WiFi 86 87 setupWiFi(); 87 88 88 - if (!wifiConnected) { 89 + if (!wifiConnected) 90 + { 89 91 Serial.println(F("WiFi failed - cannot continue")); 90 92 return; 91 93 } ··· 95 97 96 98 // Resolve PDS from identifier using slingshot 97 99 pdsUrl = resolvePDS(BLUESKY_IDENTIFIER); 98 - if (pdsUrl.length() == 0) { 100 + if (pdsUrl.length() == 0) 101 + { 99 102 Serial.println(F("Failed to resolve PDS - cannot continue")); 100 103 return; 101 104 } ··· 103 106 // Connect to ATProto 104 107 connectATProto(); 105 108 106 - if (atprotoConnected) { 109 + if (atprotoConnected) 110 + { 107 111 // Start web server 108 112 webServer.begin(&atproto, atproto.getDid(), atproto.getHandle()); 109 113 ··· 111 115 loadOrSelectGoal(); 112 116 113 117 // Start Jetstream if we have a goal 114 - if (goalUri.length() > 0) { 118 + if (goalUri.length() > 0) 119 + { 115 120 jetstream.begin(atproto.getDid()); 116 121 117 122 // Wait for calendar to finish its startup animation 118 123 Serial.println(F("Waiting for calendar to be ready...")); 119 124 delay(6000); 120 125 } 121 - } else { 126 + } 127 + else 128 + { 122 129 // Start web server anyway to show error 123 130 webServer.begin(&atproto, "", ""); 124 131 webServer.setConnectionError("Failed to connect to ATProto. Check credentials."); ··· 127 134 Serial.println(F("Setup complete!")); 128 135 } 129 136 130 - void loop() { 131 - if (!wifiConnected) { 137 + void loop() 138 + { 139 + if (!wifiConnected) 140 + { 132 141 delay(1000); 133 142 return; 134 143 } ··· 140 149 webServer.update(); 141 150 142 151 // Check for goal switch from web UI 143 - if (webServer.hasGoalSwitchRequest()) { 152 + if (webServer.hasGoalSwitchRequest()) 153 + { 144 154 handleGoalSwitch(); 145 155 } 146 156 147 157 // Check for button presses from calendar 148 - if (calendarI2C.hasButtonPress()) { 158 + if (calendarI2C.hasButtonPress()) 159 + { 149 160 CalendarButton btn = calendarI2C.getButtonPress(); 150 161 handleCalendarButton(btn.month, btn.day, btn.state); 151 162 } 152 163 153 164 // Background refresh of goals every 5 minutes 154 165 unsigned long now = millis(); 155 - if (now - lastGoalsRefresh >= GOALS_REFRESH_INTERVAL) { 166 + if (now - lastGoalsRefresh >= GOALS_REFRESH_INTERVAL) 167 + { 156 168 lastGoalsRefresh = now; 157 - if (atprotoConnected) { 169 + if (atprotoConnected) 170 + { 158 171 refreshGoals(); 159 172 160 173 // If we still don't have a goal, try to auto-select 161 - if (goalUri.length() == 0 && !cachedGoals.empty()) { 174 + if (goalUri.length() == 0 && !cachedGoals.empty()) 175 + { 162 176 goalUri = cachedGoals[0].uri; 163 177 preferences.putString(NVS_KEY_GOAL_URI, goalUri); 164 178 webServer.setCurrentGoal(goalUri, cachedGoals[0].name); ··· 171 185 } 172 186 173 187 // Update Jetstream connection 174 - if (atprotoConnected && goalUri.length() > 0) { 188 + if (atprotoConnected && goalUri.length() > 0) 189 + { 175 190 jetstream.update(); 176 191 177 192 // Sync when Jetstream (re)connects 178 193 bool jetstreamConnected = jetstream.isConnected(); 179 - if (jetstreamConnected && !wasJetstreamConnected) { 194 + if (jetstreamConnected && !wasJetstreamConnected) 195 + { 180 196 Serial.println(F("Jetstream (re)connected - syncing")); 181 197 performFullSync(); 182 198 } 183 199 wasJetstreamConnected = jetstreamConnected; 184 200 185 201 // Process any completion changes from Jetstream 186 - while (jetstream.hasEvent()) { 202 + while (jetstream.hasEvent()) 203 + { 187 204 JetstreamEvent event = jetstream.getEvent(); 188 205 handleJetstreamEvent(event); 189 206 } ··· 191 208 192 209 // Update online status for calendar animation feedback 193 210 bool isOnline = atprotoConnected && goalUri.length() > 0; 194 - if (isOnline != wasOnline) { 211 + if (isOnline != wasOnline) 212 + { 195 213 calendarI2C.setOnlineStatus(isOnline); 196 214 wasOnline = isOnline; 197 215 Serial.printf("Online status changed: %s\n", isOnline ? "online" : "offline"); ··· 200 218 delay(100); 201 219 } 202 220 203 - void setupWiFi() { 221 + void setupWiFi() 222 + { 204 223 Serial.print(F("Connecting to WiFi: ")); 205 224 Serial.println(WIFI_SSID); 206 225 ··· 213 232 WiFi.begin(WIFI_SSID, WIFI_PASSWORD); 214 233 215 234 int attempts = 0; 216 - while (WiFi.status() != WL_CONNECTED && attempts < 60) { 235 + while (WiFi.status() != WL_CONNECTED && attempts < 60) 236 + { 217 237 delay(500); 218 - if (attempts % 10 == 9) { 238 + if (attempts % 10 == 9) 239 + { 219 240 Serial.printf(" (status: %d)\n", WiFi.status()); 220 - } else { 241 + } 242 + else 243 + { 221 244 Serial.print("."); 222 245 } 223 246 attempts++; 224 247 } 225 248 226 - if (WiFi.status() == WL_CONNECTED) { 249 + if (WiFi.status() == WL_CONNECTED) 250 + { 227 251 wifiConnected = true; 228 252 Serial.println(F("\nWiFi connected!")); 229 253 Serial.print(F("IP: ")); ··· 231 255 232 256 WiFi.setSleep(true); 233 257 234 - if (MDNS.begin(HOSTNAME)) { 258 + if (MDNS.begin(HOSTNAME)) 259 + { 235 260 Serial.print(F("mDNS: ")); 236 261 Serial.print(HOSTNAME); 237 262 Serial.println(F(".local")); 238 263 } 239 - } else { 264 + } 265 + else 266 + { 240 267 Serial.println(F("\nWiFi connection failed!")); 241 268 } 242 269 } 243 270 244 - void setupNTP() { 245 - if (!wifiConnected) return; 271 + void setupNTP() 272 + { 273 + if (!wifiConnected) 274 + return; 246 275 247 276 Serial.println(F("Setting up NTP...")); 248 277 configTzTime(TIMEZONE, "pool.ntp.org", "time.nist.gov"); 249 278 250 279 time_t now = time(nullptr); 251 280 int attempts = 0; 252 - while (now < 1000000000 && attempts < 20) { 281 + while (now < 1000000000 && attempts < 20) 282 + { 253 283 delay(500); 254 284 Serial.print("."); 255 285 now = time(nullptr); 256 286 attempts++; 257 287 } 258 288 259 - if (now > 1000000000) { 289 + if (now > 1000000000) 290 + { 260 291 Serial.println(F("\nNTP synced!")); 261 292 struct tm timeinfo; 262 293 localtime_r(&now, &timeinfo); 263 294 Serial.printf("Current time: %04d-%02d-%02d %02d:%02d:%02d\n", 264 - timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, 265 - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); 266 - } else { 295 + timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, 296 + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); 297 + } 298 + else 299 + { 267 300 Serial.println(F("\nNTP sync failed")); 268 301 } 269 302 } 270 303 271 - String resolvePDS(const char* identifier) { 304 + String resolvePDS(const char *identifier) 305 + { 272 306 Serial.print(F("Resolving PDS for: ")); 273 307 Serial.println(identifier); 274 308 ··· 282 316 int httpCode = http.GET(); 283 317 284 318 String pds = ""; 285 - if (httpCode == 200) { 319 + if (httpCode == 200) 320 + { 286 321 String response = http.getString(); 287 322 JsonDocument doc; 288 323 DeserializationError error = deserializeJson(doc, response); 289 324 290 - if (!error && doc["pds"].is<const char*>()) { 325 + if (!error && doc["pds"].is<const char *>()) 326 + { 291 327 pds = doc["pds"].as<String>(); 292 328 Serial.print(F("Resolved PDS: ")); 293 329 Serial.println(pds); ··· 298 334 return pds; 299 335 } 300 336 301 - void connectATProto() { 337 + void connectATProto() 338 + { 302 339 Serial.println(F("Connecting to ATProto...")); 303 340 304 - if (atproto.createSession(pdsUrl, BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD)) { 341 + if (atproto.createSession(pdsUrl, BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD)) 342 + { 305 343 atprotoConnected = true; 306 344 Serial.println(F("ATProto session created!")); 307 345 Serial.print(F("DID: ")); 308 346 Serial.println(atproto.getDid()); 309 - } else { 347 + } 348 + else 349 + { 310 350 Serial.println(F("ATProto connection failed")); 311 351 } 312 352 } 313 353 314 - void loadOrSelectGoal() { 354 + void loadOrSelectGoal() 355 + { 315 356 // Fetch available goals 316 357 refreshGoals(); 317 358 318 359 // Try to load saved goal from NVS 319 360 goalUri = preferences.getString(NVS_KEY_GOAL_URI, ""); 320 361 321 - if (goalUri.length() > 0) { 362 + if (goalUri.length() > 0) 363 + { 322 364 // Verify saved goal still exists 323 365 bool found = false; 324 - for (const auto& goal : cachedGoals) { 325 - if (goal.uri == goalUri) { 366 + for (const auto &goal : cachedGoals) 367 + { 368 + if (goal.uri == goalUri) 369 + { 326 370 webServer.setCurrentGoal(goalUri, goal.name); 327 371 Serial.printf("Loaded saved goal: %s\n", goal.name.c_str()); 328 372 found = true; ··· 330 374 } 331 375 } 332 376 333 - if (!found) { 377 + if (!found) 378 + { 334 379 // Saved goal no longer exists 335 380 Serial.println(F("Saved goal not found, clearing")); 336 381 goalUri = ""; ··· 339 384 } 340 385 341 386 // First boot - no saved goal 342 - if (goalUri.length() == 0) { 387 + if (goalUri.length() == 0) 388 + { 343 389 handleFirstBoot(); 344 390 } 345 391 346 - if (goalUri.length() == 0) { 392 + if (goalUri.length() == 0) 393 + { 347 394 Serial.println(F("No goals found - visit goals.garden to create one")); 348 395 } 349 396 } 350 397 351 - void handleFirstBoot() { 398 + void handleFirstBoot() 399 + { 352 400 Serial.println(F("First boot detected - checking calendar state")); 353 401 354 402 // Request calendar's current LED state ··· 356 404 357 405 // Wait for calendar to send its state (up to 10 seconds) 358 406 unsigned long startTime = millis(); 359 - while (!calendarI2C.hasCalendarState() && millis() - startTime < 10000) { 407 + while (!calendarI2C.hasCalendarState() && millis() - startTime < 10000) 408 + { 360 409 calendarI2C.update(); 361 410 delay(100); 362 411 } 363 412 364 - if (!calendarI2C.hasCalendarState()) { 413 + if (!calendarI2C.hasCalendarState()) 414 + { 365 415 Serial.println(F("Timeout waiting for calendar state")); 366 416 // Fall back to selecting first goal if available 367 - if (!cachedGoals.empty()) { 417 + if (!cachedGoals.empty()) 418 + { 368 419 goalUri = cachedGoals[0].uri; 369 420 preferences.putString(NVS_KEY_GOAL_URI, goalUri); 370 421 webServer.setCurrentGoal(goalUri, cachedGoals[0].name); ··· 378 429 calendarI2C.getCalendarState(calState); 379 430 calendarI2C.clearCalendarStateRequest(); 380 431 381 - if (calendarStateHasLeds(calState)) { 432 + if (calendarStateHasLeds(calState)) 433 + { 382 434 // Calendar has existing data - create new goal and upload 383 435 Serial.println(F("Calendar has existing data - creating goal and uploading")); 384 436 385 437 int year = getCurrentYear(); 386 438 Goal newGoal = atproto.createGoal("Everyday Calendar", "", year); 387 439 388 - if (newGoal.uri.length() > 0) { 440 + if (newGoal.uri.length() > 0) 441 + { 389 442 goalUri = newGoal.uri; 390 443 preferences.putString(NVS_KEY_GOAL_URI, goalUri); 391 444 webServer.setCurrentGoal(goalUri, newGoal.name); ··· 397 450 uploadCalendarStateAsCompletions(calState); 398 451 399 452 Serial.println(F("Created goal and uploaded calendar state")); 400 - } else { 453 + } 454 + else 455 + { 401 456 Serial.println(F("Failed to create goal")); 402 457 } 403 - } else { 458 + } 459 + else 460 + { 404 461 // Calendar is blank - select first existing goal 405 462 Serial.println(F("Calendar is blank - selecting first goal")); 406 - if (!cachedGoals.empty()) { 463 + if (!cachedGoals.empty()) 464 + { 407 465 goalUri = cachedGoals[0].uri; 408 466 preferences.putString(NVS_KEY_GOAL_URI, goalUri); 409 467 webServer.setCurrentGoal(goalUri, cachedGoals[0].name); ··· 412 470 } 413 471 } 414 472 415 - bool calendarStateHasLeds(uint32_t* monthStates) { 416 - for (int month = 0; month < 12; month++) { 417 - if (monthStates[month] != 0) { 473 + bool calendarStateHasLeds(uint32_t *monthStates) 474 + { 475 + for (int month = 0; month < 12; month++) 476 + { 477 + if (monthStates[month] != 0) 478 + { 418 479 return true; 419 480 } 420 481 } 421 482 return false; 422 483 } 423 484 424 - void uploadCalendarStateAsCompletions(uint32_t* monthStates) { 485 + void uploadCalendarStateAsCompletions(uint32_t *monthStates) 486 + { 425 487 int year = getCurrentYear(); 426 488 int count = 0; 427 489 428 - for (int month = 0; month < 12; month++) { 490 + for (int month = 0; month < 12; month++) 491 + { 429 492 uint32_t state = monthStates[month]; 430 - if (state == 0) continue; 493 + if (state == 0) 494 + continue; 431 495 432 - for (int day = 0; day < 31; day++) { 433 - if (state & ((uint32_t)1 << day)) { 496 + for (int day = 0; day < 31; day++) 497 + { 498 + if (state & ((uint32_t)1 << day)) 499 + { 434 500 // LED is on - create completion 435 501 String completedAt = getISO8601Date(year, month + 1, day + 1); 436 502 String rkey = atproto.createCompletion(goalUri, year, month + 1, day + 1, completedAt); 437 - if (rkey.length() > 0) { 503 + if (rkey.length() > 0) 504 + { 438 505 count++; 439 506 } 440 507 // Small delay to avoid overwhelming the PDS ··· 446 513 Serial.printf("Uploaded %d completions from calendar\n", count); 447 514 } 448 515 449 - void refreshGoals() { 516 + void refreshGoals() 517 + { 450 518 int year = getCurrentYear(); 451 519 cachedGoals = atproto.getGoals(year); 452 520 webServer.setGoals(cachedGoals); 453 521 Serial.printf("Refreshed goals: %d found\n", cachedGoals.size()); 454 522 } 455 523 456 - void handleGoalSwitch() { 524 + void handleGoalSwitch() 525 + { 457 526 String newUri = webServer.getRequestedGoalUri(); 458 527 459 528 // Don't switch if it's the same goal 460 - if (newUri == goalUri) { 529 + if (newUri == goalUri) 530 + { 461 531 webServer.clearGoalSwitchRequest(); 462 532 return; 463 533 } ··· 480 550 atproto.cacheCompletions({}); 481 551 482 552 // 5. Find goal name for web UI 483 - for (const auto& goal : cachedGoals) { 484 - if (goal.uri == goalUri) { 553 + for (const auto &goal : cachedGoals) 554 + { 555 + if (goal.uri == goalUri) 556 + { 485 557 webServer.setCurrentGoal(goalUri, goal.name); 486 558 break; 487 559 } ··· 496 568 Serial.println(F("Goal switch complete")); 497 569 } 498 570 499 - void handleCalendarButton(uint8_t month, uint8_t day, bool state) { 500 - if (!atprotoConnected || goalUri.length() == 0) { 571 + void handleCalendarButton(uint8_t month, uint8_t day, bool state) 572 + { 573 + if (!atprotoConnected || goalUri.length() == 0) 574 + { 501 575 Serial.println(F("Not connected or no goal, ignoring button")); 502 576 return; 503 577 } 504 578 505 579 // In mode 0, block button presses when not synced (Jetstream not connected) 506 - if (OFFLINE_SYNC_MODE == 0 && !jetstream.isConnected()) { 580 + if (OFFLINE_SYNC_MODE == 0 && !jetstream.isConnected()) 581 + { 507 582 Serial.println(F("Offline sync mode 0: blocking button while not synced")); 508 583 // Revert the LED state 509 584 calendarI2C.setState(month, day, !state); ··· 522 597 int targetMonth = month + 1; 523 598 int targetDay = day + 1; 524 599 525 - if (state) { 600 + if (state) 601 + { 526 602 String completedAt; 527 - if (targetMonth == currentMonth && targetDay == currentDay) { 603 + if (targetMonth == currentMonth && targetDay == currentDay) 604 + { 528 605 completedAt = getISO8601Timestamp(now); 529 - } else { 606 + } 607 + else 608 + { 530 609 completedAt = getISO8601Date(currentYear, targetMonth, targetDay); 531 610 } 532 611 533 612 String rkey = atproto.createCompletion( 534 613 goalUri, currentYear, targetMonth, targetDay, completedAt); 535 614 536 - if (rkey.length() > 0) { 615 + if (rkey.length() > 0) 616 + { 537 617 Serial.printf("Created completion: %s\n", rkey.c_str()); 538 - } else { 618 + } 619 + else 620 + { 539 621 Serial.println(F("Failed to create completion")); 540 622 calendarI2C.setState(month, day, false); 541 623 } 542 - } else { 624 + } 625 + else 626 + { 543 627 String rkey = findCompletionRkey(currentYear, targetMonth, targetDay); 544 - if (rkey.length() > 0) { 545 - if (atproto.deleteCompletion(rkey)) { 628 + if (rkey.length() > 0) 629 + { 630 + if (atproto.deleteCompletion(rkey)) 631 + { 546 632 Serial.printf("Deleted completion: %s\n", rkey.c_str()); 547 - } else { 633 + } 634 + else 635 + { 548 636 Serial.println(F("Failed to delete completion")); 549 637 calendarI2C.setState(month, day, true); 550 638 } ··· 552 640 } 553 641 } 554 642 555 - void handleJetstreamEvent(JetstreamEvent& event) { 556 - if (event.collection != "garden.goals.completion") return; 643 + void handleJetstreamEvent(JetstreamEvent &event) 644 + { 645 + if (event.collection != "garden.goals.completion") 646 + return; 557 647 558 648 int currentYear = getCurrentYear(); 559 649 int year, month, day; 560 650 561 - if (event.action == JetstreamAction::Create) { 562 - if (event.goalUri != goalUri) return; 651 + if (event.action == JetstreamAction::Create) 652 + { 653 + if (event.goalUri != goalUri) 654 + return; 563 655 year = event.year; 564 656 month = event.month; 565 657 day = event.day; 566 658 567 659 // Add to cache so we can look it up when deleted 568 660 atproto.addCompletionToCache(event.rkey, year, month, day, event.goalUri); 569 - 570 - } else if (event.action == JetstreamAction::Delete) { 571 - if (!atproto.findCompletionByRkey(event.rkey, &year, &month, &day)) { 661 + } 662 + else if (event.action == JetstreamAction::Delete) 663 + { 664 + if (!atproto.findCompletionByRkey(event.rkey, &year, &month, &day)) 665 + { 572 666 Serial.printf("Jetstream delete: rkey %s not found in cache\n", event.rkey.c_str()); 573 667 return; 574 668 } 575 669 // Remove from cache 576 670 atproto.removeCompletionFromCache(event.rkey); 577 - 578 - } else { 671 + } 672 + else 673 + { 579 674 return; 580 675 } 581 676 582 - if (year != currentYear) return; 677 + if (year != currentYear) 678 + return; 583 679 584 680 uint8_t calMonth = month - 1; 585 681 uint8_t calDay = day - 1; 586 682 587 - if (calMonth >= 12 || calDay >= 31) return; 683 + if (calMonth >= 12 || calDay >= 31) 684 + return; 588 685 589 - if (event.action == JetstreamAction::Create) { 590 - Serial.printf("Jetstream: setting LED month=%d day=%d ON\n", calMonth, calDay + 1); 686 + if (event.action == JetstreamAction::Create) 687 + { 591 688 calendarI2C.setState(calMonth, calDay, true); 592 - } else if (event.action == JetstreamAction::Delete) { 593 - Serial.printf("Jetstream: setting LED month=%d day=%d OFF\n", calMonth, calDay + 1); 689 + } 690 + else if (event.action == JetstreamAction::Delete) 691 + { 594 692 calendarI2C.setState(calMonth, calDay, false); 595 693 } 596 694 } 597 695 598 - void performFullSync() { 599 - if (!atprotoConnected || goalUri.length() == 0) return; 696 + void performFullSync() 697 + { 698 + if (!atprotoConnected || goalUri.length() == 0) 699 + return; 600 700 601 701 int currentYear = getCurrentYear(); 602 702 603 703 // Mode 1: Calendar wins - sync local changes TO goals.garden 604 - if (OFFLINE_SYNC_MODE == 1) { 704 + if (OFFLINE_SYNC_MODE == 1) 705 + { 605 706 Serial.println(F("Performing full sync (calendar wins)...")); 606 707 607 708 // Get calendar's current state 608 709 calendarI2C.requestCalendarState(); 609 710 unsigned long startTime = millis(); 610 - while (!calendarI2C.hasCalendarState() && millis() - startTime < 5000) { 711 + while (!calendarI2C.hasCalendarState() && millis() - startTime < 5000) 712 + { 611 713 calendarI2C.update(); 612 714 delay(50); 613 715 } 614 716 615 - if (!calendarI2C.hasCalendarState()) { 717 + if (!calendarI2C.hasCalendarState()) 718 + { 616 719 Serial.println(F("Timeout waiting for calendar state, falling back to goals.garden")); 617 720 // Fall through to goals.garden wins 618 - } else { 721 + } 722 + else 723 + { 619 724 uint32_t calState[12]; 620 725 calendarI2C.getCalendarState(calState); 621 726 calendarI2C.clearCalendarStateRequest(); ··· 623 728 // Get goals.garden state 624 729 std::vector<Completion> completions = atproto.getCompletions(goalUri, currentYear); 625 730 uint32_t remoteState[12] = {0}; 626 - for (const auto& completion : completions) { 627 - if (completion.year == currentYear) { 731 + for (const auto &completion : completions) 732 + { 733 + if (completion.year == currentYear) 734 + { 628 735 uint8_t calMonth = completion.month - 1; 629 736 uint8_t calDay = completion.day - 1; 630 - if (calMonth < 12 && calDay < 31) { 737 + if (calMonth < 12 && calDay < 31) 738 + { 631 739 remoteState[calMonth] |= ((uint32_t)1 << calDay); 632 740 } 633 741 } ··· 635 743 636 744 // Sync differences: calendar -> goals.garden 637 745 int created = 0, deleted = 0; 638 - for (int month = 0; month < 12; month++) { 746 + for (int month = 0; month < 12; month++) 747 + { 639 748 uint32_t local = calState[month]; 640 749 uint32_t remote = remoteState[month]; 641 750 642 - for (int day = 0; day < 31; day++) { 751 + for (int day = 0; day < 31; day++) 752 + { 643 753 bool localOn = (local & ((uint32_t)1 << day)) != 0; 644 754 bool remoteOn = (remote & ((uint32_t)1 << day)) != 0; 645 755 646 - if (localOn && !remoteOn) { 756 + if (localOn && !remoteOn) 757 + { 647 758 // Create completion on goals.garden 648 759 String completedAt = getISO8601Date(currentYear, month + 1, day + 1); 649 760 String rkey = atproto.createCompletion(goalUri, currentYear, month + 1, day + 1, completedAt); 650 - if (rkey.length() > 0) created++; 761 + if (rkey.length() > 0) 762 + created++; 651 763 delay(50); 652 - } else if (!localOn && remoteOn) { 764 + } 765 + else if (!localOn && remoteOn) 766 + { 653 767 // Delete completion from goals.garden 654 768 String rkey = atproto.findCompletionRkey(currentYear, month + 1, day + 1); 655 - if (rkey.length() > 0 && atproto.deleteCompletion(rkey)) { 769 + if (rkey.length() > 0 && atproto.deleteCompletion(rkey)) 770 + { 656 771 deleted++; 657 772 } 658 773 delay(50); ··· 679 794 std::vector<Completion> completions = atproto.getCompletions(goalUri, currentYear); 680 795 681 796 uint32_t monthStates[12] = {0}; 682 - for (const auto& completion : completions) { 683 - if (completion.year == currentYear) { 797 + for (const auto &completion : completions) 798 + { 799 + if (completion.year == currentYear) 800 + { 684 801 uint8_t calMonth = completion.month - 1; 685 802 uint8_t calDay = completion.day - 1; 686 - if (calMonth < 12 && calDay < 31) { 803 + if (calMonth < 12 && calDay < 31) 804 + { 687 805 monthStates[calMonth] |= ((uint32_t)1 << calDay); 688 806 } 689 807 } ··· 696 814 Serial.printf("Full sync complete: %d completions\n", completions.size()); 697 815 } 698 816 699 - String findCompletionRkey(int year, int month, int day) { 817 + String findCompletionRkey(int year, int month, int day) 818 + { 700 819 return atproto.findCompletionRkey(year, month, day); 701 820 } 702 821 703 - int getCurrentYear() { 822 + int getCurrentYear() 823 + { 704 824 time_t now = time(nullptr); 705 825 struct tm timeinfo; 706 826 localtime_r(&now, &timeinfo); 707 827 return timeinfo.tm_year + 1900; 708 828 } 709 829 710 - String getISO8601Timestamp(time_t t) { 830 + String getISO8601Timestamp(time_t t) 831 + { 711 832 struct tm timeinfo; 712 833 gmtime_r(&t, &timeinfo); 713 834 char buf[32]; 714 835 snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.000Z", 715 - timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, 716 - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); 836 + timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, 837 + timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); 717 838 return String(buf); 718 839 } 719 840 720 - String getISO8601Date(int year, int month, int day) { 841 + String getISO8601Date(int year, int month, int day) 842 + { 721 843 char buf[32]; 722 844 snprintf(buf, sizeof(buf), "%04d-%02d-%02dT00:00:00.000Z", year, month, day); 723 845 return String(buf);
+2 -1
firmware/esp32/GoalsGardenSync/atproto_client.cpp
··· 240 240 encodedGoalUri.replace("/", "%2F"); 241 241 242 242 while (hasMore) { 243 - String url = String(CONSTELLATION_API) + 243 + String url = String(CONSTELLATION_URL) + 244 + "/xrpc/blue.microcosm.links.getBacklinks" 244 245 "?subject=" + encodedGoalUri + 245 246 "&source=garden.goals.completion%3AgoalUri&limit=100"; 246 247 if (cursor.length() > 0) {
+6 -8
firmware/esp32/GoalsGardenSync/config.h
··· 18 18 #endif 19 19 20 20 // Jetstream WebSocket server for real-time ATProto events 21 - #ifndef JETSTREAM_HOST 22 - #define JETSTREAM_HOST "jetstream2.us-east.bsky.network" 23 - #endif 24 - #ifndef JETSTREAM_PORT 25 - #define JETSTREAM_PORT 443 21 + // Use wss:// for SSL (port 443) or ws:// for plain (port 80) 22 + #ifndef JETSTREAM_URL 23 + #define JETSTREAM_URL "wss://jetstream2.us-east.bsky.network" 26 24 #endif 27 25 28 - // Constellation API for backlink resolution 29 - #ifndef CONSTELLATION_API 30 - #define CONSTELLATION_API "https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks" 26 + // Constellation API base URL for backlink resolution 27 + #ifndef CONSTELLATION_URL 28 + #define CONSTELLATION_URL "https://constellation.microcosm.blue" 31 29 #endif 32 30 33 31 // I2C pins for communication with calendar
+2 -3
firmware/esp32/GoalsGardenSync/config.local.h.example
··· 36 36 37 37 // ATProto service URLs (optional - defaults are provided) 38 38 // Jetstream WebSocket server for real-time events 39 - // #define JETSTREAM_HOST "jetstream2.us-east.bsky.network" 40 - // #define JETSTREAM_PORT 443 39 + // #define JETSTREAM_URL "wss://jetstream2.us-east.bsky.network" 41 40 // Constellation API for backlink resolution 42 - // #define CONSTELLATION_API "https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks" 41 + // #define CONSTELLATION_URL "https://constellation.microcosm.blue" 43 42 44 43 #endif
+30 -3
firmware/esp32/GoalsGardenSync/jetstream_client.cpp
··· 11 11 12 12 Serial.println(F("Connecting to Jetstream...")); 13 13 14 - // Build subscription URL with filters 15 - // We want to subscribe to garden.goals.completion records for our DID 14 + // Parse JETSTREAM_URL to extract scheme, host, and port 15 + String url = JETSTREAM_URL; 16 + bool useSSL = url.startsWith("wss://"); 17 + 18 + // Remove scheme prefix 19 + if (useSSL) { 20 + url = url.substring(6); // Remove "wss://" 21 + } else if (url.startsWith("ws://")) { 22 + url = url.substring(5); // Remove "ws://" 23 + } 24 + 25 + // Default ports based on scheme 26 + uint16_t port = useSSL ? 443 : 80; 27 + 28 + // Check for explicit port in URL (host:port) 29 + int colonIdx = url.indexOf(':'); 30 + String host; 31 + if (colonIdx > 0) { 32 + host = url.substring(0, colonIdx); 33 + port = url.substring(colonIdx + 1).toInt(); 34 + } else { 35 + host = url; 36 + } 37 + 38 + // Build subscription path with filters 16 39 String path = "/subscribe?wantedCollections=garden.goals.completion"; 17 40 path += "&wantedDids=" + did; 18 41 19 - webSocket.beginSSL(JETSTREAM_HOST, JETSTREAM_PORT, path.c_str()); 42 + if (useSSL) { 43 + webSocket.beginSSL(host.c_str(), port, path.c_str()); 44 + } else { 45 + webSocket.begin(host.c_str(), port, path.c_str()); 46 + } 20 47 webSocket.onEvent(webSocketEvent); 21 48 webSocket.setReconnectInterval(5000); 22 49 }