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

Configure Feed

Select the types of activity you want to include in your feed.

Add web UI for goal selection

- Remove hardcoded GOAL_URI from config
- Add web server at everydaycalendar.local for selecting goals
- Store selected goal in NVS flash (persists across reboots)
- Auto-select first goal on boot if none stored
- Background refresh of goals list every 5 minutes
- Goal switching: disconnects Jetstream, clears state, reconnects

Also updates completion record structure:
- Use simple goalUri field instead of goal strongref (uri+cid)
- Remove goalCid from codebase as it's no longer needed

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

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

+441 -94
+2 -1
CLAUDE.md
··· 64 64 Copy `firmware/esp32/GoalsGardenSync/config.local.h.example` to `config.local.h` and fill in: 65 65 - WiFi credentials 66 66 - Bluesky identifier and app password 67 - - Goal URI from goals.garden 67 + 68 + Goal selection is done via the web UI at `http://everydaycalendar.local` - the selected goal is stored in NVS flash.
+182 -82
firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino
··· 12 12 * - Bidirectional sync with goals.garden 13 13 * - Jetstream subscription for real-time updates 14 14 * - NTP time synchronization 15 + * - Web UI for goal selection at http://everydaycalendar.local 15 16 */ 16 17 17 18 #include <WiFi.h> ··· 20 21 #include <WiFiClientSecure.h> 21 22 #include <WebSocketsClient.h> 22 23 #include <ArduinoJson.h> 24 + #include <Preferences.h> 23 25 #include <time.h> 24 26 25 27 #include "config.h" 26 28 #include "calendar_i2c.h" 27 29 #include "atproto_client.h" 28 30 #include "jetstream_client.h" 31 + #include "web_server.h" 29 32 30 - // Forward declarations (needed when compiled as .cpp) 33 + // Forward declarations 31 34 void setupWiFi(); 32 35 void setupNTP(); 33 36 String resolvePDS(const char* identifier); 34 37 void connectATProto(); 38 + void loadOrSelectGoal(); 39 + void refreshGoals(); 40 + void handleGoalSwitch(); 35 41 void handleCalendarButton(uint8_t month, uint8_t day, bool state); 36 42 void handleJetstreamEvent(JetstreamEvent& event); 37 43 void performFullSync(); 38 44 String findCompletionRkey(int year, int month, int day); 39 45 String getISO8601Timestamp(time_t t); 40 46 String getISO8601Date(int year, int month, int day); 47 + int getCurrentYear(); 41 48 42 49 // Global objects 43 50 CalendarI2C calendarI2C; 44 51 ATProtoClient atproto; 45 52 JetstreamClient jetstream; 53 + GoalsWebServer webServer; 54 + Preferences preferences; 46 55 47 56 // Runtime state 48 57 String pdsUrl; 49 - String goalUri = GOAL_URI; 50 - String goalCid; // Derived at runtime 58 + String goalUri; 59 + std::vector<Goal> cachedGoals; 51 60 52 61 // Connection state 53 62 bool wifiConnected = false; 54 63 bool atprotoConnected = false; 55 - bool wasJetstreamConnected = false; // Track Jetstream state for (re)connect detection 64 + bool wasJetstreamConnected = false; 65 + 66 + // Timing for background refresh 67 + unsigned long lastGoalsRefresh = 0; 56 68 57 69 void setup() { 58 70 Serial.begin(115200); ··· 62 74 63 75 // Initialize I2C communication with calendar 64 76 calendarI2C.begin(CALENDAR_I2C_SDA, CALENDAR_I2C_SCL); 77 + 78 + // Initialize NVS 79 + preferences.begin(NVS_NAMESPACE, false); 65 80 66 81 // Setup WiFi 67 82 setupWiFi(); ··· 84 99 // Connect to ATProto 85 100 connectATProto(); 86 101 102 + if (atprotoConnected) { 103 + // Start web server 104 + webServer.begin(&atproto, atproto.getDid()); 105 + 106 + // Load saved goal or select first available 107 + loadOrSelectGoal(); 108 + 109 + // Start Jetstream if we have a goal 110 + if (goalUri.length() > 0) { 111 + jetstream.begin(atproto.getDid()); 112 + 113 + // Wait for calendar to finish its startup animation 114 + Serial.println(F("Waiting for calendar to be ready...")); 115 + delay(6000); 116 + } 117 + } else { 118 + // Start web server anyway to show error 119 + webServer.begin(&atproto, ""); 120 + webServer.setConnectionError("Failed to connect to ATProto. Check credentials."); 121 + } 122 + 87 123 Serial.println(F("Setup complete!")); 88 124 } 89 125 ··· 94 130 } 95 131 96 132 // Process I2C communication with calendar 97 - // (Calendar polls for state automatically, ESP32 responds from internal state) 98 133 calendarI2C.update(); 99 134 135 + // Update web server 136 + webServer.update(); 137 + 138 + // Check for goal switch from web UI 139 + if (webServer.hasGoalSwitchRequest()) { 140 + handleGoalSwitch(); 141 + } 142 + 100 143 // Check for button presses from calendar 101 144 if (calendarI2C.hasButtonPress()) { 102 145 CalendarButton btn = calendarI2C.getButtonPress(); 103 146 handleCalendarButton(btn.month, btn.day, btn.state); 104 147 } 105 148 149 + // Background refresh of goals every 5 minutes 150 + unsigned long now = millis(); 151 + if (now - lastGoalsRefresh >= GOALS_REFRESH_INTERVAL) { 152 + lastGoalsRefresh = now; 153 + if (atprotoConnected) { 154 + refreshGoals(); 155 + 156 + // If we still don't have a goal, try to auto-select 157 + if (goalUri.length() == 0 && !cachedGoals.empty()) { 158 + goalUri = cachedGoals[0].uri; 159 + preferences.putString(NVS_KEY_GOAL_URI, goalUri); 160 + webServer.setCurrentGoal(goalUri, cachedGoals[0].name); 161 + Serial.printf("Auto-selected goal: %s\n", cachedGoals[0].name.c_str()); 162 + 163 + // Start Jetstream now that we have a goal 164 + jetstream.begin(atproto.getDid()); 165 + } 166 + } 167 + } 168 + 106 169 // Update Jetstream connection 107 - if (atprotoConnected) { 170 + if (atprotoConnected && goalUri.length() > 0) { 108 171 jetstream.update(); 109 172 110 - // Sync when Jetstream (re)connects to get current state / catch up on missed events 173 + // Sync when Jetstream (re)connects 111 174 bool jetstreamConnected = jetstream.isConnected(); 112 175 if (jetstreamConnected && !wasJetstreamConnected) { 113 176 Serial.println(F("Jetstream (re)connected - syncing")); ··· 122 185 } 123 186 } 124 187 125 - delay(100); // Delay to reduce CPU usage and heat 188 + delay(100); 126 189 } 127 190 128 191 void setupWiFi() { 129 192 Serial.print(F("Connecting to WiFi: ")); 130 193 Serial.println(WIFI_SSID); 131 194 132 - // Clean start 133 195 WiFi.disconnect(true); 134 196 WiFi.mode(WIFI_OFF); 135 197 delay(1000); ··· 155 217 Serial.print(F("IP: ")); 156 218 Serial.println(WiFi.localIP()); 157 219 158 - // Enable WiFi power saving 159 220 WiFi.setSleep(true); 160 221 161 - // Start mDNS responder 162 222 if (MDNS.begin(HOSTNAME)) { 163 223 Serial.print(F("mDNS: ")); 164 224 Serial.print(HOSTNAME); ··· 166 226 } 167 227 } else { 168 228 Serial.println(F("\nWiFi connection failed!")); 169 - Serial.println(F("Check WIFI_SSID and WIFI_PASSWORD in config.local.h")); 170 229 } 171 230 } 172 231 ··· 174 233 if (!wifiConnected) return; 175 234 176 235 Serial.println(F("Setting up NTP...")); 177 - 178 - // Configure timezone 179 236 configTzTime(TIMEZONE, "pool.ntp.org", "time.nist.gov"); 180 237 181 - // Wait for time sync 182 238 time_t now = time(nullptr); 183 239 int attempts = 0; 184 240 while (now < 1000000000 && attempts < 20) { ··· 206 262 207 263 HTTPClient http; 208 264 WiFiClientSecure client; 209 - client.setInsecure(); // Skip certificate validation for simplicity 265 + client.setInsecure(); 210 266 211 267 String url = String(SLINGSHOT_API) + identifier; 212 268 ··· 216 272 String pds = ""; 217 273 if (httpCode == 200) { 218 274 String response = http.getString(); 219 - 220 275 JsonDocument doc; 221 276 DeserializationError error = deserializeJson(doc, response); 222 277 ··· 224 279 pds = doc["pds"].as<String>(); 225 280 Serial.print(F("Resolved PDS: ")); 226 281 Serial.println(pds); 227 - } else { 228 - Serial.println(F("Failed to parse slingshot response")); 229 282 } 230 - } else { 231 - Serial.printf("Slingshot request failed: %d\n", httpCode); 232 283 } 233 284 234 285 http.end(); ··· 243 294 Serial.println(F("ATProto session created!")); 244 295 Serial.print(F("DID: ")); 245 296 Serial.println(atproto.getDid()); 246 - 247 - // Start Jetstream subscription - sync will happen when it connects 248 - jetstream.begin(atproto.getDid()); 249 - 250 - // Wait for calendar to finish its startup animation 251 - // Calendar takes ~5s for honey drip + fade + delays 252 - Serial.println(F("Waiting for calendar to be ready...")); 253 - delay(6000); 254 297 } else { 255 298 Serial.println(F("ATProto connection failed")); 256 - Serial.println(F("Check BLUESKY_IDENTIFIER and BLUESKY_APP_PASSWORD in config.local.h")); 257 299 } 258 300 } 259 301 302 + void loadOrSelectGoal() { 303 + // Fetch available goals 304 + refreshGoals(); 305 + 306 + // Try to load saved goal from NVS 307 + goalUri = preferences.getString(NVS_KEY_GOAL_URI, ""); 308 + 309 + if (goalUri.length() > 0) { 310 + // Verify saved goal still exists 311 + bool found = false; 312 + for (const auto& goal : cachedGoals) { 313 + if (goal.uri == goalUri) { 314 + webServer.setCurrentGoal(goalUri, goal.name); 315 + Serial.printf("Loaded saved goal: %s\n", goal.name.c_str()); 316 + found = true; 317 + break; 318 + } 319 + } 320 + 321 + if (!found) { 322 + // Saved goal no longer exists 323 + Serial.println(F("Saved goal not found, clearing")); 324 + goalUri = ""; 325 + preferences.remove(NVS_KEY_GOAL_URI); 326 + } 327 + } 328 + 329 + // Auto-select first goal if none saved 330 + if (goalUri.length() == 0 && !cachedGoals.empty()) { 331 + goalUri = cachedGoals[0].uri; 332 + preferences.putString(NVS_KEY_GOAL_URI, goalUri); 333 + webServer.setCurrentGoal(goalUri, cachedGoals[0].name); 334 + Serial.printf("Auto-selected goal: %s\n", cachedGoals[0].name.c_str()); 335 + } 336 + 337 + if (goalUri.length() == 0) { 338 + Serial.println(F("No goals found - visit goals.garden to create one")); 339 + } 340 + } 341 + 342 + void refreshGoals() { 343 + int year = getCurrentYear(); 344 + cachedGoals = atproto.getGoals(year); 345 + webServer.setGoals(cachedGoals); 346 + Serial.printf("Refreshed goals: %d found\n", cachedGoals.size()); 347 + } 348 + 349 + void handleGoalSwitch() { 350 + String newUri = webServer.getRequestedGoalUri(); 351 + 352 + // Don't switch if it's the same goal 353 + if (newUri == goalUri) { 354 + webServer.clearGoalSwitchRequest(); 355 + return; 356 + } 357 + 358 + Serial.printf("Switching goal to: %s\n", newUri.c_str()); 359 + 360 + // 1. Disconnect Jetstream 361 + jetstream.disconnect(); 362 + wasJetstreamConnected = false; 363 + 364 + // 2. Clear calendar state 365 + calendarI2C.clearState(); 366 + calendarI2C.setReady(false); 367 + 368 + // 3. Update goal in memory and NVS 369 + goalUri = newUri; 370 + preferences.putString(NVS_KEY_GOAL_URI, goalUri); 371 + 372 + // 4. Clear completion cache 373 + atproto.cacheCompletions({}); 374 + 375 + // 5. Find goal name for web UI 376 + for (const auto& goal : cachedGoals) { 377 + if (goal.uri == goalUri) { 378 + webServer.setCurrentGoal(goalUri, goal.name); 379 + break; 380 + } 381 + } 382 + 383 + // 6. Clear the request flag 384 + webServer.clearGoalSwitchRequest(); 385 + 386 + // 7. Reconnect Jetstream (will trigger sync on connect) 387 + jetstream.begin(atproto.getDid()); 388 + 389 + Serial.println(F("Goal switch complete")); 390 + } 391 + 260 392 void handleCalendarButton(uint8_t month, uint8_t day, bool state) { 261 - if (!atprotoConnected) { 262 - Serial.println(F("Not connected, ignoring button")); 393 + if (!atprotoConnected || goalUri.length() == 0) { 394 + Serial.println(F("Not connected or no goal, ignoring button")); 263 395 return; 264 396 } 265 397 266 398 Serial.printf("Calendar button: month=%d, day=%d, state=%d\n", month, day, state); 267 399 268 - // Get current time info 269 400 time_t now = time(nullptr); 270 401 struct tm timeinfo; 271 402 localtime_r(&now, &timeinfo); 272 403 int currentYear = timeinfo.tm_year + 1900; 273 - int currentMonth = timeinfo.tm_mon + 1; // 1-12 404 + int currentMonth = timeinfo.tm_mon + 1; 274 405 int currentDay = timeinfo.tm_mday; 275 406 276 - // Convert calendar coordinates to date 277 - // month is 0-11 (Jan-Dec), day is 0-30 (1st-31st) 278 - int targetMonth = month + 1; // 1-12 279 - int targetDay = day + 1; // 1-31 407 + int targetMonth = month + 1; 408 + int targetDay = day + 1; 280 409 281 410 if (state) { 282 - // Create completion record 283 411 String completedAt; 284 412 if (targetMonth == currentMonth && targetDay == currentDay) { 285 - // Today - use current time 286 413 completedAt = getISO8601Timestamp(now); 287 414 } else { 288 - // Not today - use midnight UTC for that day 289 415 completedAt = getISO8601Date(currentYear, targetMonth, targetDay); 290 416 } 291 417 292 418 String rkey = atproto.createCompletion( 293 - goalUri, 294 - goalCid, 295 - currentYear, 296 - targetMonth, 297 - targetDay, 298 - completedAt 299 - ); 419 + goalUri, currentYear, targetMonth, targetDay, completedAt); 300 420 301 421 if (rkey.length() > 0) { 302 422 Serial.printf("Created completion: %s\n", rkey.c_str()); 303 423 } else { 304 424 Serial.println(F("Failed to create completion")); 305 - // Revert the internal state (calendar will pick up on next poll) 306 425 calendarI2C.setState(month, day, false); 307 426 } 308 427 } else { 309 - // Delete completion record 310 428 String rkey = findCompletionRkey(currentYear, targetMonth, targetDay); 311 429 if (rkey.length() > 0) { 312 430 if (atproto.deleteCompletion(rkey)) { 313 431 Serial.printf("Deleted completion: %s\n", rkey.c_str()); 314 432 } else { 315 433 Serial.println(F("Failed to delete completion")); 316 - // Revert the internal state (calendar will pick up on next poll) 317 434 calendarI2C.setState(month, day, true); 318 435 } 319 436 } ··· 323 440 void handleJetstreamEvent(JetstreamEvent& event) { 324 441 if (event.collection != "garden.goals.completion") return; 325 442 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 - 443 + int currentYear = getCurrentYear(); 332 444 int year, month, day; 333 445 334 446 if (event.action == JetstreamAction::Create) { 335 - // For creates, check if it's for our goal 336 447 if (event.goalUri != goalUri) return; 337 - 338 448 year = event.year; 339 449 month = event.month; 340 450 day = event.day; 341 - 342 451 } else if (event.action == JetstreamAction::Delete) { 343 - // For deletes, look up the completion by rkey in our cache 344 452 if (!atproto.findCompletionByRkey(event.rkey, &year, &month, &day)) { 345 - // Not in our cache, so probably not for our goal 346 453 return; 347 454 } 348 455 } else { 349 456 return; 350 457 } 351 458 352 - // Only process if it's for current year 353 459 if (year != currentYear) return; 354 460 355 - // Convert to calendar coordinates (0-indexed) 356 - uint8_t calMonth = month - 1; // 0-11 357 - uint8_t calDay = day - 1; // 0-30 461 + uint8_t calMonth = month - 1; 462 + uint8_t calDay = day - 1; 358 463 359 464 if (calMonth >= 12 || calDay >= 31) return; 360 465 361 - // Update internal state (calendar will pick up on next poll) 362 466 if (event.action == JetstreamAction::Create) { 363 467 calendarI2C.setState(calMonth, calDay, true); 364 468 } else if (event.action == JetstreamAction::Delete) { ··· 367 471 } 368 472 369 473 void performFullSync() { 370 - if (!atprotoConnected) return; 474 + if (!atprotoConnected || goalUri.length() == 0) return; 371 475 372 476 Serial.println(F("Performing full sync from goals.garden...")); 373 477 374 - // Get current year 375 - time_t now = time(nullptr); 376 - struct tm timeinfo; 377 - localtime_r(&now, &timeinfo); 378 - int currentYear = timeinfo.tm_year + 1900; 478 + int currentYear = getCurrentYear(); 379 479 380 - // Fetch all completions for this goal 381 480 std::vector<Completion> completions = atproto.getCompletions(goalUri, currentYear); 382 481 383 - // Build state array 384 482 uint32_t monthStates[12] = {0}; 385 483 for (const auto& completion : completions) { 386 484 if (completion.year == currentYear) { 387 - uint8_t calMonth = completion.month - 1; // 0-11 388 - uint8_t calDay = completion.day - 1; // 0-30 485 + uint8_t calMonth = completion.month - 1; 486 + uint8_t calDay = completion.day - 1; 389 487 if (calMonth < 12 && calDay < 31) { 390 488 monthStates[calMonth] |= ((uint32_t)1 << calDay); 391 489 } 392 490 } 393 491 } 394 492 395 - // Update internal state (calendar will pick up on next poll) 396 493 calendarI2C.setFullState(monthStates); 397 - 398 - // Store completion rkeys for later deletion lookups 399 494 atproto.cacheCompletions(completions); 400 - 401 - // Mark ESP32 as ready - calendar can now receive valid state 402 495 calendarI2C.setReady(true); 403 496 404 497 Serial.printf("Full sync complete: %d completions\n", completions.size()); ··· 406 499 407 500 String findCompletionRkey(int year, int month, int day) { 408 501 return atproto.findCompletionRkey(year, month, day); 502 + } 503 + 504 + int getCurrentYear() { 505 + time_t now = time(nullptr); 506 + struct tm timeinfo; 507 + localtime_r(&now, &timeinfo); 508 + return timeinfo.tm_year + 1900; 409 509 } 410 510 411 511 String getISO8601Timestamp(time_t t) {
+3 -5
firmware/esp32/GoalsGardenSync/atproto_client.cpp
··· 175 175 goal.uri = record["uri"].as<String>(); 176 176 goal.cid = record["cid"].as<String>(); 177 177 goal.name = value["name"].as<String>(); 178 + goal.description = value["description"].as<String>(); 178 179 goal.year = goalYear; 179 180 180 181 goals.push_back(goal); ··· 281 282 return completions; 282 283 } 283 284 284 - String ATProtoClient::createCompletion(const String& goalUri, const String& goalCid, 285 + String ATProtoClient::createCompletion(const String& goalUri, 285 286 int year, int month, int day, 286 287 const String& completedAt) { 287 288 JsonDocument doc; ··· 294 295 record["month"] = month; 295 296 record["day"] = day; 296 297 record["completedAt"] = completedAt; 297 - 298 - JsonObject goal = record["goal"].to<JsonObject>(); 299 - goal["uri"] = goalUri; 300 - goal["cid"] = goalCid; 298 + record["goalUri"] = goalUri; 301 299 302 300 String body; 303 301 serializeJson(doc, body);
+2 -1
firmware/esp32/GoalsGardenSync/atproto_client.h
··· 17 17 String uri; 18 18 String cid; 19 19 String name; 20 + String description; 20 21 int year; 21 22 }; 22 23 ··· 33 34 34 35 // Completions 35 36 std::vector<Completion> getCompletions(const String& goalUri, int year); 36 - String createCompletion(const String& goalUri, const String& goalCid, 37 + String createCompletion(const String& goalUri, 37 38 int year, int month, int day, const String& completedAt); 38 39 bool deleteCompletion(const String& rkey); 39 40
+7
firmware/esp32/GoalsGardenSync/config.h
··· 24 24 // Slingshot API for resolving PDS from identifier 25 25 #define SLINGSHOT_API "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=" 26 26 27 + // NVS storage for persistent settings 28 + #define NVS_NAMESPACE "goalsync" 29 + #define NVS_KEY_GOAL_URI "goal_uri" 30 + 31 + // Background refresh interval (milliseconds) 32 + #define GOALS_REFRESH_INTERVAL 300000 // 5 minutes 33 + 27 34 // Days in each month (non-leap year) 28 35 const uint8_t DAYS_IN_MONTH[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; 29 36
-4
firmware/esp32/GoalsGardenSync/config.local.h.example
··· 14 14 // Create an App Password at: https://bsky.app/settings/app-passwords 15 15 #define BLUESKY_APP_PASSWORD "xxxx-xxxx-xxxx-xxxx" 16 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 17 // Network hostname (optional) - accessible as <hostname>.local 22 18 #define HOSTNAME "everydaycalendar" 23 19
+5 -1
firmware/esp32/GoalsGardenSync/jetstream_client.cpp
··· 31 31 void JetstreamClient::disconnect() { 32 32 webSocket.disconnect(); 33 33 connected = false; 34 + // Clear any pending events from old subscription 35 + while (!eventQueue.empty()) { 36 + eventQueue.pop(); 37 + } 34 38 } 35 39 36 40 bool JetstreamClient::isConnected() { ··· 126 130 event.year = record["year"] | 0; 127 131 event.month = record["month"] | 0; 128 132 event.day = record["day"] | 0; 129 - event.goalUri = record["goal"]["uri"].as<String>(); 133 + event.goalUri = record["goalUri"].as<String>(); 130 134 131 135 } else if (operation == "delete") { 132 136 event.action = JetstreamAction::Delete;
+187
firmware/esp32/GoalsGardenSync/web_server.cpp
··· 1 + #include "web_server.h" 2 + 3 + // HTML template stored in flash 4 + static const char HTML_HEADER[] PROGMEM = R"rawliteral(<!DOCTYPE html> 5 + <html lang="en"> 6 + <head> 7 + <meta charset="UTF-8"> 8 + <meta name="viewport" content="width=device-width,initial-scale=1"> 9 + <title>Every Day Calendar</title> 10 + <style> 11 + *{box-sizing:border-box} 12 + body{font-family:system-ui,-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:20px;background:#f5f5f5;color:#333} 13 + h1{margin:0 0 8px;font-size:1.5em} 14 + .user{color:#666;margin-bottom:24px} 15 + .user a{color:#1976d2} 16 + .error{background:#ffebee;border:1px solid #f44336;color:#c62828;padding:16px;border-radius:8px;margin-bottom:24px} 17 + .no-goals{background:#fff3e0;border:1px solid #ff9800;padding:16px;border-radius:8px;text-align:center} 18 + .no-goals a{color:#e65100;font-weight:500} 19 + .goal{background:#fff;padding:16px;margin:8px 0;border-radius:8px;border:1px solid #ddd;cursor:pointer} 20 + .goal:hover{border-color:#999} 21 + .goal.selected{background:#e8f5e9;border-color:#4caf50} 22 + .goal input{margin-right:12px} 23 + .goal label{display:flex;align-items:flex-start;cursor:pointer} 24 + .goal-info{flex:1} 25 + .goal-name{font-weight:500} 26 + .goal-desc{color:#666;font-size:0.9em;margin-top:4px;display:none} 27 + .goal.selected .goal-desc{display:block} 28 + button{background:#4caf50;color:#fff;border:none;padding:14px 28px;border-radius:8px;font-size:1em;cursor:pointer;width:100%;margin-top:16px} 29 + button:hover{background:#43a047} 30 + button:disabled{background:#ccc;cursor:not-allowed} 31 + </style> 32 + </head> 33 + <body> 34 + )rawliteral"; 35 + 36 + static const char HTML_FOOTER[] PROGMEM = R"rawliteral(</body></html>)rawliteral"; 37 + 38 + void GoalsWebServer::begin(ATProtoClient* atproto, const String& did) { 39 + atprotoClient = atproto; 40 + userDid = did; 41 + 42 + server.on("/", HTTP_GET, [this]() { handleRoot(); }); 43 + server.on("/select-goal", HTTP_POST, [this]() { handleSelectGoal(); }); 44 + server.onNotFound([this]() { handleNotFound(); }); 45 + 46 + server.begin(); 47 + Serial.println(F("Web server started on port 80")); 48 + } 49 + 50 + void GoalsWebServer::update() { 51 + server.handleClient(); 52 + } 53 + 54 + bool GoalsWebServer::hasGoalSwitchRequest() { 55 + return switchRequested; 56 + } 57 + 58 + String GoalsWebServer::getRequestedGoalUri() { 59 + return requestedGoalUri; 60 + } 61 + 62 + void GoalsWebServer::clearGoalSwitchRequest() { 63 + switchRequested = false; 64 + requestedGoalUri = ""; 65 + } 66 + 67 + void GoalsWebServer::setCurrentGoal(const String& uri, const String& name) { 68 + currentGoalUri = uri; 69 + currentGoalName = name; 70 + } 71 + 72 + void GoalsWebServer::setGoals(const std::vector<Goal>& goals) { 73 + cachedGoals = goals; 74 + } 75 + 76 + void GoalsWebServer::setConnectionError(const String& error) { 77 + connectionError = error; 78 + } 79 + 80 + void GoalsWebServer::clearConnectionError() { 81 + connectionError = ""; 82 + } 83 + 84 + void GoalsWebServer::handleRoot() { 85 + String html = generatePage(); 86 + server.send(200, "text/html", html); 87 + } 88 + 89 + void GoalsWebServer::handleSelectGoal() { 90 + if (server.hasArg("goal_uri")) { 91 + requestedGoalUri = server.arg("goal_uri"); 92 + switchRequested = true; 93 + } 94 + 95 + // Redirect back to main page 96 + server.sendHeader("Location", "/"); 97 + server.send(303); 98 + } 99 + 100 + void GoalsWebServer::handleNotFound() { 101 + server.sendHeader("Location", "/"); 102 + server.send(303); 103 + } 104 + 105 + String GoalsWebServer::generatePage() { 106 + String html; 107 + html.reserve(4096); 108 + 109 + // Header 110 + html += FPSTR(HTML_HEADER); 111 + 112 + html += F("<h1>Every Day Calendar</h1>"); 113 + 114 + // User info 115 + html += F("<p class=\"user\">Logged in as <a href=\"https://goals.garden/"); 116 + html += escapeHtml(userDid); 117 + html += F("\" target=\"_blank\">"); 118 + html += escapeHtml(userDid); 119 + html += F("</a></p>"); 120 + 121 + // Connection error 122 + if (connectionError.length() > 0) { 123 + html += F("<div class=\"error\"><strong>Connection Error</strong><br>"); 124 + html += escapeHtml(connectionError); 125 + html += F("<br><br>Please check your credentials in config.local.h and re-flash the ESP32.</div>"); 126 + html += FPSTR(HTML_FOOTER); 127 + return html; 128 + } 129 + 130 + // No goals case 131 + if (cachedGoals.empty()) { 132 + html += F("<div class=\"no-goals\">"); 133 + html += F("<p>No goals found on your account.</p>"); 134 + html += F("<p><a href=\"https://goals.garden/\" target=\"_blank\">Create a goal on goals.garden</a></p>"); 135 + html += F("<p style=\"color:#666;font-size:0.9em;margin-top:12px\">This page will automatically update when a goal is created.</p>"); 136 + html += F("</div>"); 137 + html += FPSTR(HTML_FOOTER); 138 + return html; 139 + } 140 + 141 + // Goal selection form 142 + html += F("<h2 style=\"font-size:1.1em;margin:24px 0 12px\">Select Goal</h2>"); 143 + html += F("<form method=\"POST\" action=\"/select-goal\">"); 144 + 145 + for (const auto& goal : cachedGoals) { 146 + bool isSelected = (goal.uri == currentGoalUri); 147 + html += F("<div class=\"goal"); 148 + if (isSelected) html += F(" selected"); 149 + html += F("\">"); 150 + html += F("<label><input type=\"radio\" name=\"goal_uri\" value=\""); 151 + html += escapeHtml(goal.uri); 152 + html += F("\""); 153 + if (isSelected) html += F(" checked"); 154 + html += F("><div class=\"goal-info\"><div class=\"goal-name\">"); 155 + html += escapeHtml(goal.name); 156 + html += F("</div>"); 157 + if (goal.description.length() > 0) { 158 + html += F("<div class=\"goal-desc\">"); 159 + html += escapeHtml(goal.description); 160 + html += F("</div>"); 161 + } 162 + html += F("</div></label></div>"); 163 + } 164 + 165 + html += F("<button type=\"submit\">Save Selection</button>"); 166 + html += F("</form>"); 167 + 168 + html += FPSTR(HTML_FOOTER); 169 + return html; 170 + } 171 + 172 + String GoalsWebServer::escapeHtml(const String& text) { 173 + String result; 174 + result.reserve(text.length() + 16); 175 + for (unsigned int i = 0; i < text.length(); i++) { 176 + char c = text.charAt(i); 177 + switch (c) { 178 + case '&': result += "&amp;"; break; 179 + case '<': result += "&lt;"; break; 180 + case '>': result += "&gt;"; break; 181 + case '"': result += "&quot;"; break; 182 + case '\'': result += "&#39;"; break; 183 + default: result += c; 184 + } 185 + } 186 + return result; 187 + }
+52
firmware/esp32/GoalsGardenSync/web_server.h
··· 1 + #ifndef WEB_SERVER_H 2 + #define WEB_SERVER_H 3 + 4 + #include <Arduino.h> 5 + #include <WebServer.h> 6 + #include <vector> 7 + #include "atproto_client.h" 8 + 9 + class GoalsWebServer { 10 + public: 11 + void begin(ATProtoClient* atproto, const String& userDid); 12 + void update(); 13 + 14 + // Goal switch request (checked from main loop) 15 + bool hasGoalSwitchRequest(); 16 + String getRequestedGoalUri(); 17 + void clearGoalSwitchRequest(); 18 + 19 + // State updates from main loop 20 + void setCurrentGoal(const String& uri, const String& name); 21 + void setGoals(const std::vector<Goal>& goals); 22 + void setConnectionError(const String& error); 23 + void clearConnectionError(); 24 + 25 + private: 26 + WebServer server{80}; 27 + ATProtoClient* atprotoClient = nullptr; 28 + String userDid; 29 + 30 + // Current state 31 + String currentGoalUri; 32 + String currentGoalName; 33 + String connectionError; 34 + 35 + // Cached goals list 36 + std::vector<Goal> cachedGoals; 37 + 38 + // Pending goal switch 39 + bool switchRequested = false; 40 + String requestedGoalUri; 41 + 42 + // Route handlers 43 + void handleRoot(); 44 + void handleSelectGoal(); 45 + void handleNotFound(); 46 + 47 + // HTML generation 48 + String generatePage(); 49 + String escapeHtml(const String& text); 50 + }; 51 + 52 + #endif
+1
src/main.cpp
··· 8 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 + #include "../firmware/esp32/GoalsGardenSync/web_server.cpp" 11 12 #include "../firmware/esp32/GoalsGardenSync/GoalsGardenSync.ino" 12 13 #elif defined(BUILD_CALENDAR) 13 14 #include "../firmware/sketches/EverydayCalendar/EverydayCalendar.ino"