From fcdaf1fefbbbc7192f26dc3a33bf6cc89d149794 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Feb 2026 12:32:17 +1100 Subject: [PATCH 01/14] feat: Add queue state sync for reliable ESP32 navigation Implements local queue storage on ESP32 with optimistic UI updates for responsive button navigation. Key changes: Backend: - Add ControllerQueueItem and ControllerQueueSync GraphQL types - Add queueItemUuid field to LedUpdate for reconciliation - Update navigateQueue mutation to accept queueItemUuid (direct nav) - Send ControllerQueueSync on initial connection and queue changes ESP32 Firmware: - Add LocalQueueItem struct and queue storage (up to 150 items) - Implement optimistic navigation with immediate display updates - Handle ControllerQueueSync events to maintain local queue state - Navigate using queueItemUuid instead of direction for reliability - Reconcile optimistic updates with backend LedUpdate responses This fixes erratic navigation when the same climb appears multiple times in the queue, and enables fast-skipping through the queue. Co-Authored-By: Claude Opus 4.5 --- .../src/graphql_ws_client.cpp | 91 +++++- .../graphql-ws-client/src/graphql_ws_client.h | 24 ++ .../lilygo-display/src/lilygo_display.cpp | 308 +++++++++++++++++- .../libs/lilygo-display/src/lilygo_display.h | 142 +++++++- .../projects/climb-queue-display/src/main.cpp | 307 ++++++++++++++--- .../graphql/resolvers/controller/mutations.ts | 122 +++++++ .../resolvers/controller/subscriptions.ts | 207 ++++++++---- packages/shared-schema/src/schema.ts | 53 ++- packages/shared-schema/src/types.ts | 38 ++- 9 files changed, 1171 insertions(+), 121 deletions(-) diff --git a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp index 30748002..f77f12c5 100644 --- a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp +++ b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp @@ -10,8 +10,8 @@ const char* GraphQLWSClient::KEY_PORT = "gql_port"; const char* GraphQLWSClient::KEY_PATH = "gql_path"; GraphQLWSClient::GraphQLWSClient() - : state(GraphQLConnectionState::DISCONNECTED), messageCallback(nullptr), stateCallback(nullptr), serverPort(443), - useSSL(true), lastPingTime(0), lastPongTime(0), reconnectTime(0), lastSentLedHash(0), currentDisplayHash(0) {} + : state(GraphQLConnectionState::DISCONNECTED), messageCallback(nullptr), stateCallback(nullptr), queueSyncCallback(nullptr), + serverPort(443), useSSL(true), lastPingTime(0), lastPongTime(0), reconnectTime(0), lastSentLedHash(0), currentDisplayHash(0) {} void GraphQLWSClient::begin(const char* host, uint16_t port, const char* path, const char* apiKeyParam) { // Parse protocol prefix from host (ws:// or wss://) @@ -155,6 +155,32 @@ void GraphQLWSClient::send(const char* query, const char* variables) { ws.sendTXT(msg); } +void GraphQLWSClient::sendMutation(const char* mutationId, const char* mutation, const char* variables) { + if (state != GraphQLConnectionState::SUBSCRIBED && state != GraphQLConnectionState::CONNECTION_ACK) { + Logger.logln("GraphQL: Cannot send mutation - not connected"); + return; + } + + JsonDocument doc; + doc["id"] = mutationId; + doc["type"] = "subscribe"; // graphql-ws uses subscribe for mutations too + + JsonObject payload = doc["payload"].to(); + payload["query"] = mutation; + + if (variables) { + JsonDocument varsDoc; + deserializeJson(varsDoc, variables); + payload["variables"] = varsDoc; + } + + String msg; + serializeJson(doc, msg); + ws.sendTXT(msg); + + Logger.logln("GraphQL: Sent mutation %s", mutationId); +} + void GraphQLWSClient::setMessageCallback(GraphQLMessageCallback callback) { messageCallback = callback; } @@ -163,6 +189,10 @@ void GraphQLWSClient::setStateCallback(GraphQLStateCallback callback) { stateCallback = callback; } +void GraphQLWSClient::setQueueSyncCallback(GraphQLQueueSyncCallback callback) { + queueSyncCallback = callback; +} + void GraphQLWSClient::onWebSocketEvent(WStype_t type, uint8_t* payload, size_t length) { switch (type) { case WStype_DISCONNECTED: @@ -245,6 +275,8 @@ void GraphQLWSClient::handleMessage(uint8_t* payload, size_t length) { if (typename_ && strcmp(typename_, "LedUpdate") == 0) { handleLedUpdate(event); + } else if (typename_ && strcmp(typename_, "ControllerQueueSync") == 0) { + handleQueueSync(event); } else if (typename_ && strcmp(typename_, "ControllerPing") == 0) { Logger.logln("GraphQL: Received ping from server"); } @@ -341,6 +373,61 @@ void GraphQLWSClient::handleLedUpdate(JsonObject& data) { delete[] ledCommands; } +void GraphQLWSClient::handleQueueSync(JsonObject& data) { + JsonArray queueArray = data["queue"]; + int currentIndex = data["currentIndex"] | -1; + + if (queueArray.isNull()) { + Logger.logln("GraphQL: QueueSync with null queue"); + return; + } + + int count = queueArray.size(); + Logger.logln("GraphQL: QueueSync received: %d items, currentIndex: %d", count, currentIndex); + + if (!queueSyncCallback) { + Logger.logln("GraphQL: No queue sync callback registered"); + return; + } + + // Build the sync data structure + ControllerQueueSyncData syncData; + syncData.count = min(count, ControllerQueueSyncData::MAX_ITEMS); + syncData.currentIndex = currentIndex; + + int i = 0; + for (JsonObject item : queueArray) { + if (i >= ControllerQueueSyncData::MAX_ITEMS) break; + + const char* uuid = item["uuid"] | ""; + const char* climbUuid = item["climbUuid"] | ""; + const char* name = item["name"] | ""; + const char* grade = item["grade"] | ""; + const char* gradeColor = item["gradeColor"] | ""; + + // Copy with truncation + strncpy(syncData.items[i].uuid, uuid, sizeof(syncData.items[i].uuid) - 1); + syncData.items[i].uuid[sizeof(syncData.items[i].uuid) - 1] = '\0'; + + strncpy(syncData.items[i].climbUuid, climbUuid, sizeof(syncData.items[i].climbUuid) - 1); + syncData.items[i].climbUuid[sizeof(syncData.items[i].climbUuid) - 1] = '\0'; + + strncpy(syncData.items[i].name, name, sizeof(syncData.items[i].name) - 1); + syncData.items[i].name[sizeof(syncData.items[i].name) - 1] = '\0'; + + strncpy(syncData.items[i].grade, grade, sizeof(syncData.items[i].grade) - 1); + syncData.items[i].grade[sizeof(syncData.items[i].grade) - 1] = '\0'; + + strncpy(syncData.items[i].gradeColor, gradeColor, sizeof(syncData.items[i].gradeColor) - 1); + syncData.items[i].gradeColor[sizeof(syncData.items[i].gradeColor) - 1] = '\0'; + + i++; + } + + // Call the callback + queueSyncCallback(syncData); +} + void GraphQLWSClient::sendLedPositions(const LedCommand* commands, int count, int angle) { Logger.logln("GraphQL: sendLedPositions called: %d LEDs, state=%d", count, (int)state); diff --git a/embedded/libs/graphql-ws-client/src/graphql_ws_client.h b/embedded/libs/graphql-ws-client/src/graphql_ws_client.h index 96bf45de..80d4d17d 100644 --- a/embedded/libs/graphql-ws-client/src/graphql_ws_client.h +++ b/embedded/libs/graphql-ws-client/src/graphql_ws_client.h @@ -21,8 +21,24 @@ class NordicUartBLE; enum class GraphQLConnectionState { DISCONNECTED, CONNECTING, CONNECTED, CONNECTION_INIT, CONNECTION_ACK, SUBSCRIBED }; +// Forward declaration for queue sync data +struct ControllerQueueSyncData { + static const int MAX_ITEMS = 150; + struct Item { + char uuid[37]; + char climbUuid[37]; + char name[32]; + char grade[12]; + char gradeColor[8]; // Hex color string + }; + Item items[MAX_ITEMS]; + int count; + int currentIndex; +}; + typedef void (*GraphQLMessageCallback)(JsonDocument& doc); typedef void (*GraphQLStateCallback)(GraphQLConnectionState state); +typedef void (*GraphQLQueueSyncCallback)(const ControllerQueueSyncData& data); class GraphQLWSClient { public: @@ -44,16 +60,23 @@ class GraphQLWSClient { // Send a GraphQL query/mutation void send(const char* query, const char* variables = nullptr); + // Send a named GraphQL mutation (for tracking completion) + void sendMutation(const char* mutationId, const char* mutation, const char* variables = nullptr); + // Send LED positions from Bluetooth to backend (to match climb) void sendLedPositions(const LedCommand* commands, int count, int angle); // Callbacks void setMessageCallback(GraphQLMessageCallback callback); void setStateCallback(GraphQLStateCallback callback); + void setQueueSyncCallback(GraphQLQueueSyncCallback callback); // Handle LED update from backend void handleLedUpdate(JsonObject& data); + // Handle queue sync from backend + void handleQueueSync(JsonObject& data); + // Config keys static const char* KEY_HOST; static const char* KEY_PORT; @@ -67,6 +90,7 @@ class GraphQLWSClient { GraphQLConnectionState state; GraphQLMessageCallback messageCallback; GraphQLStateCallback stateCallback; + GraphQLQueueSyncCallback queueSyncCallback; String serverHost; uint16_t serverPort; diff --git a/embedded/libs/lilygo-display/src/lilygo_display.cpp b/embedded/libs/lilygo-display/src/lilygo_display.cpp index d9ef55cc..4df90f48 100644 --- a/embedded/libs/lilygo-display/src/lilygo_display.cpp +++ b/embedded/libs/lilygo-display/src/lilygo_display.cpp @@ -79,7 +79,8 @@ LGFX_TDisplayS3::LGFX_TDisplayS3() { LilyGoDisplay::LilyGoDisplay() : _wifiConnected(false), _backendConnected(false), _bleEnabled(false), _bleConnected(false), _hasClimb(false), - _angle(0), _boardType("kilter"), _hasQRCode(false) {} + _angle(0), _boardType("kilter"), _queueIndex(-1), _queueTotal(0), _hasNavigation(false), _hasQRCode(false), + _queueCount(0), _currentQueueIndex(-1), _pendingNavigation(false) {} LilyGoDisplay::~LilyGoDisplay() {} @@ -257,11 +258,31 @@ void LilyGoDisplay::clearHistory() { _history.clear(); } +void LilyGoDisplay::setNavigationContext(const QueueNavigationItem& prevClimb, const QueueNavigationItem& nextClimb, + int currentIndex, int totalCount) { + _prevClimb = prevClimb; + _nextClimb = nextClimb; + _queueIndex = currentIndex; + _queueTotal = totalCount; + _hasNavigation = true; +} + +void LilyGoDisplay::clearNavigationContext() { + _prevClimb.clear(); + _nextClimb.clear(); + _queueIndex = -1; + _queueTotal = 0; + _hasNavigation = false; +} + void LilyGoDisplay::refresh() { drawStatusBar(); + drawPrevClimbIndicator(); drawCurrentClimb(); drawQRCode(); + drawNextClimbIndicator(); drawHistory(); + drawButtonHints(); } void LilyGoDisplay::drawStatusBar() { @@ -301,6 +322,58 @@ void LilyGoDisplay::drawStatusBar() { } } +void LilyGoDisplay::drawPrevClimbIndicator() { + // Clear previous indicator area + _display.fillRect(0, PREV_INDICATOR_Y, SCREEN_WIDTH, PREV_INDICATOR_HEIGHT, COLOR_BACKGROUND); + + // Only draw if we have navigation and a previous climb + if (!_hasNavigation || !_prevClimb.isValid) { + return; + } + + _display.setFont(&fonts::Font0); + _display.setTextDatum(lgfx::middle_left); + + // Draw left arrow + _display.setTextColor(COLOR_ACCENT); + _display.drawString("<", 4, PREV_INDICATOR_Y + PREV_INDICATOR_HEIGHT / 2); + + // Draw "Prev:" label + _display.setTextColor(COLOR_TEXT_DIM); + _display.drawString("Prev:", 14, PREV_INDICATOR_Y + PREV_INDICATOR_HEIGHT / 2); + + // Truncate name if needed + String name = _prevClimb.name; + if (name.length() > 10) { + name = name.substring(0, 8) + ".."; + } + + // Draw climb name + _display.setTextColor(COLOR_TEXT); + _display.drawString(name.c_str(), 50, PREV_INDICATOR_Y + PREV_INDICATOR_HEIGHT / 2); + + // Draw grade with color + uint16_t gradeColor = COLOR_TEXT; + if (_prevClimb.gradeColor.length() > 0) { + gradeColor = hexToRgb565(_prevClimb.gradeColor.c_str()); + } else if (_prevClimb.grade.length() > 0) { + gradeColor = getGradeColor(_prevClimb.grade.c_str()); + } + + // Extract V-grade for display + String grade = _prevClimb.grade; + int slashPos = grade.indexOf('/'); + if (slashPos > 0) { + grade = grade.substring(slashPos + 1); + } + + _display.setTextDatum(lgfx::middle_right); + _display.setTextColor(gradeColor); + _display.drawString(grade.c_str(), SCREEN_WIDTH - 4, PREV_INDICATOR_Y + PREV_INDICATOR_HEIGHT / 2); + + _display.setTextDatum(lgfx::top_left); +} + void LilyGoDisplay::drawCurrentClimb() { int yStart = CURRENT_CLIMB_Y; @@ -413,6 +486,58 @@ void LilyGoDisplay::drawQRCode() { _display.setTextDatum(lgfx::top_left); } +void LilyGoDisplay::drawNextClimbIndicator() { + // Clear next indicator area + _display.fillRect(0, NEXT_INDICATOR_Y, SCREEN_WIDTH, NEXT_INDICATOR_HEIGHT, COLOR_BACKGROUND); + + // Only draw if we have navigation and a next climb + if (!_hasNavigation || !_nextClimb.isValid) { + return; + } + + _display.setFont(&fonts::Font0); + _display.setTextDatum(lgfx::middle_left); + + // Draw right arrow + _display.setTextColor(COLOR_ACCENT); + _display.drawString(">", 4, NEXT_INDICATOR_Y + NEXT_INDICATOR_HEIGHT / 2); + + // Draw "Next:" label + _display.setTextColor(COLOR_TEXT_DIM); + _display.drawString("Next:", 14, NEXT_INDICATOR_Y + NEXT_INDICATOR_HEIGHT / 2); + + // Truncate name if needed + String name = _nextClimb.name; + if (name.length() > 10) { + name = name.substring(0, 8) + ".."; + } + + // Draw climb name + _display.setTextColor(COLOR_TEXT); + _display.drawString(name.c_str(), 50, NEXT_INDICATOR_Y + NEXT_INDICATOR_HEIGHT / 2); + + // Draw grade with color + uint16_t gradeColor = COLOR_TEXT; + if (_nextClimb.gradeColor.length() > 0) { + gradeColor = hexToRgb565(_nextClimb.gradeColor.c_str()); + } else if (_nextClimb.grade.length() > 0) { + gradeColor = getGradeColor(_nextClimb.grade.c_str()); + } + + // Extract V-grade for display + String grade = _nextClimb.grade; + int slashPos = grade.indexOf('/'); + if (slashPos > 0) { + grade = grade.substring(slashPos + 1); + } + + _display.setTextDatum(lgfx::middle_right); + _display.setTextColor(gradeColor); + _display.drawString(grade.c_str(), SCREEN_WIDTH - 4, NEXT_INDICATOR_Y + NEXT_INDICATOR_HEIGHT / 2); + + _display.setTextDatum(lgfx::top_left); +} + void LilyGoDisplay::drawHistory() { int yStart = HISTORY_Y; @@ -468,6 +593,44 @@ void LilyGoDisplay::drawHistory() { } } +void LilyGoDisplay::drawButtonHints() { + // Clear button hint area + _display.fillRect(0, BUTTON_HINT_Y, SCREEN_WIDTH, BUTTON_HINT_HEIGHT, COLOR_BACKGROUND); + + // Only show hints if we have navigation + if (!_hasNavigation || _queueTotal <= 1) { + return; + } + + _display.setFont(&fonts::Font0); + _display.setTextDatum(lgfx::middle_center); + + // Draw hint bar background + _display.fillRect(0, BUTTON_HINT_Y, SCREEN_WIDTH, BUTTON_HINT_HEIGHT, 0x2104); // Very dark gray + + // Show position indicator in center + _display.setTextColor(COLOR_TEXT_DIM); + char posStr[16]; + snprintf(posStr, sizeof(posStr), "%d/%d", _queueIndex + 1, _queueTotal); + _display.drawString(posStr, SCREEN_WIDTH / 2, BUTTON_HINT_Y + BUTTON_HINT_HEIGHT / 2); + + // Draw left button hint (if there's a previous climb) + if (_prevClimb.isValid) { + _display.setTextColor(COLOR_ACCENT); + _display.setTextDatum(lgfx::middle_left); + _display.drawString("", SCREEN_WIDTH - 4, BUTTON_HINT_Y + BUTTON_HINT_HEIGHT / 2); + } + + _display.setTextDatum(lgfx::top_left); +} + uint16_t LilyGoDisplay::hexToRgb565(const char* hex) { // Check for null or empty string first, then validate format if (!hex || strlen(hex) < 7 || hex[0] != '#') { @@ -486,3 +649,146 @@ uint16_t LilyGoDisplay::hexToRgb565(const char* hex) { // Convert to RGB565 return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3); } + +// ============================================ +// Queue Management Methods +// ============================================ + +void LilyGoDisplay::setQueueFromSync(LocalQueueItem* items, int count, int currentIndex) { + // Clear existing queue + clearQueue(); + + // Copy items (up to max) + _queueCount = min(count, MAX_QUEUE_SIZE); + for (int i = 0; i < _queueCount; i++) { + _queueItems[i] = items[i]; + } + + _currentQueueIndex = currentIndex; + _pendingNavigation = false; + + // Update navigation context to match new queue state + if (_queueCount > 0 && _currentQueueIndex >= 0 && _currentQueueIndex < _queueCount) { + // Build prev/next from local queue + QueueNavigationItem prevItem, nextItem; + + if (_currentQueueIndex > 0) { + const LocalQueueItem& prev = _queueItems[_currentQueueIndex - 1]; + prevItem = QueueNavigationItem(prev.name, prev.grade, ""); + } + + if (_currentQueueIndex < _queueCount - 1) { + const LocalQueueItem& next = _queueItems[_currentQueueIndex + 1]; + nextItem = QueueNavigationItem(next.name, next.grade, ""); + } + + setNavigationContext(prevItem, nextItem, _currentQueueIndex, _queueCount); + } else { + clearNavigationContext(); + } +} + +void LilyGoDisplay::clearQueue() { + for (int i = 0; i < MAX_QUEUE_SIZE; i++) { + _queueItems[i].clear(); + } + _queueCount = 0; + _currentQueueIndex = -1; + _pendingNavigation = false; +} + +const LocalQueueItem* LilyGoDisplay::getQueueItem(int index) const { + if (index < 0 || index >= _queueCount) { + return nullptr; + } + return &_queueItems[index]; +} + +const LocalQueueItem* LilyGoDisplay::getCurrentQueueItem() const { + return getQueueItem(_currentQueueIndex); +} + +const LocalQueueItem* LilyGoDisplay::getPreviousQueueItem() const { + return getQueueItem(_currentQueueIndex - 1); +} + +const LocalQueueItem* LilyGoDisplay::getNextQueueItem() const { + return getQueueItem(_currentQueueIndex + 1); +} + +bool LilyGoDisplay::navigateToPrevious() { + if (!canNavigatePrevious()) { + return false; + } + + _currentQueueIndex--; + _pendingNavigation = true; + + // Update navigation context for immediate UI feedback + const LocalQueueItem* current = getCurrentQueueItem(); + if (current) { + QueueNavigationItem prevItem, nextItem; + + if (_currentQueueIndex > 0) { + const LocalQueueItem* prev = getPreviousQueueItem(); + if (prev) { + prevItem = QueueNavigationItem(prev->name, prev->grade, ""); + } + } + + if (_currentQueueIndex < _queueCount - 1) { + const LocalQueueItem* next = getNextQueueItem(); + if (next) { + nextItem = QueueNavigationItem(next->name, next->grade, ""); + } + } + + setNavigationContext(prevItem, nextItem, _currentQueueIndex, _queueCount); + } + + return true; +} + +bool LilyGoDisplay::navigateToNext() { + if (!canNavigateNext()) { + return false; + } + + _currentQueueIndex++; + _pendingNavigation = true; + + // Update navigation context for immediate UI feedback + const LocalQueueItem* current = getCurrentQueueItem(); + if (current) { + QueueNavigationItem prevItem, nextItem; + + if (_currentQueueIndex > 0) { + const LocalQueueItem* prev = getPreviousQueueItem(); + if (prev) { + prevItem = QueueNavigationItem(prev->name, prev->grade, ""); + } + } + + if (_currentQueueIndex < _queueCount - 1) { + const LocalQueueItem* next = getNextQueueItem(); + if (next) { + nextItem = QueueNavigationItem(next->name, next->grade, ""); + } + } + + setNavigationContext(prevItem, nextItem, _currentQueueIndex, _queueCount); + } + + return true; +} + +void LilyGoDisplay::setCurrentQueueIndex(int index) { + if (index >= 0 && index < _queueCount) { + _currentQueueIndex = index; + } +} + +const char* LilyGoDisplay::getPendingQueueItemUuid() const { + const LocalQueueItem* current = getCurrentQueueItem(); + return current ? current->uuid : nullptr; +} diff --git a/embedded/libs/lilygo-display/src/lilygo_display.h b/embedded/libs/lilygo-display/src/lilygo_display.h index 9f0b2cba..44cbd2a5 100644 --- a/embedded/libs/lilygo-display/src/lilygo_display.h +++ b/embedded/libs/lilygo-display/src/lilygo_display.h @@ -43,35 +43,48 @@ #define LILYGO_SCREEN_HEIGHT 320 // ============================================ -// UI Layout (170x320 Portrait) +// UI Layout (170x320 Portrait) - Condensed for Navigation // ============================================ // Status bar at top #define STATUS_BAR_HEIGHT 20 #define STATUS_BAR_Y 0 -// Current climb section -#define CURRENT_CLIMB_Y 20 -#define CURRENT_CLIMB_HEIGHT 120 +// Previous climb indicator (top navigation) +#define PREV_INDICATOR_Y 20 +#define PREV_INDICATOR_HEIGHT 22 + +// Current climb section (condensed) +#define CURRENT_CLIMB_Y 42 +#define CURRENT_CLIMB_HEIGHT 78 // Climb name area -#define CLIMB_NAME_Y 25 -#define CLIMB_NAME_HEIGHT 40 +#define CLIMB_NAME_Y 47 +#define CLIMB_NAME_HEIGHT 30 + +// Grade badge area (closer to title) +#define GRADE_Y 77 +#define GRADE_HEIGHT 40 -// Grade badge area -#define GRADE_Y 70 -#define GRADE_HEIGHT 60 +// QR code section (moved up) +#define QR_SECTION_Y 120 +#define QR_SECTION_HEIGHT 95 +#define QR_CODE_SIZE 80 -// QR code section -#define QR_SECTION_Y 140 -#define QR_SECTION_HEIGHT 115 -#define QR_CODE_SIZE 100 +// Next climb indicator +#define NEXT_INDICATOR_Y 215 +#define NEXT_INDICATOR_HEIGHT 22 -// History section -#define HISTORY_Y 255 -#define HISTORY_HEIGHT 65 +// History section (previous climbs in queue) +#define HISTORY_Y 237 +#define HISTORY_HEIGHT 72 #define HISTORY_ITEM_HEIGHT 18 #define HISTORY_MAX_ITEMS 3 +#define HISTORY_LABEL_HEIGHT 12 + +// Button hint bar at bottom +#define BUTTON_HINT_Y 309 +#define BUTTON_HINT_HEIGHT 11 // ============================================ // Colors (RGB565) @@ -103,6 +116,38 @@ class LGFX_TDisplayS3 : public lgfx::LGFX_Device { LGFX_TDisplayS3(); }; +// ============================================ +// Queue Item for Local Queue Storage +// ============================================ + +// Maximum number of queue items to store locally +#define MAX_QUEUE_SIZE 150 + +// Optimized queue item structure (~88 bytes per item) +struct LocalQueueItem { + char uuid[37]; // Queue item UUID (for navigation) + char climbUuid[37]; // Climb UUID (for display/matching) + char name[32]; // Climb name (truncated for display) + char grade[12]; // Grade string + uint16_t gradeColorRgb; // RGB565 color (saves parsing) + + LocalQueueItem() : gradeColorRgb(0xFFFF) { + uuid[0] = '\0'; + climbUuid[0] = '\0'; + name[0] = '\0'; + grade[0] = '\0'; + } + + bool isValid() const { return uuid[0] != '\0'; } + void clear() { + uuid[0] = '\0'; + climbUuid[0] = '\0'; + name[0] = '\0'; + grade[0] = '\0'; + gradeColorRgb = 0xFFFF; + } +}; + // ============================================ // Climb History Entry // ============================================ @@ -113,6 +158,27 @@ struct ClimbHistoryEntry { String gradeColor; // Hex color from backend }; +// ============================================ +// Queue Navigation Item (for prev/next display) +// ============================================ + +struct QueueNavigationItem { + String name; + String grade; + String gradeColor; + bool isValid; + + QueueNavigationItem() : isValid(false) {} + QueueNavigationItem(const String& n, const String& g, const String& c) + : name(n), grade(g), gradeColor(c), isValid(true) {} + void clear() { + name = ""; + grade = ""; + gradeColor = ""; + isValid = false; + } +}; + // ============================================ // LilyGo Display Manager // ============================================ @@ -144,6 +210,34 @@ class LilyGoDisplay { void addToHistory(const char* name, const char* grade, const char* gradeColor); void clearHistory(); + // Queue navigation (for prev/next indicators) + void setNavigationContext(const QueueNavigationItem& prevClimb, const QueueNavigationItem& nextClimb, + int currentIndex, int totalCount); + void clearNavigationContext(); + + // Local queue management + void setQueueFromSync(LocalQueueItem* items, int count, int currentIndex); + void clearQueue(); + int getQueueCount() const { return _queueCount; } + int getCurrentQueueIndex() const { return _currentQueueIndex; } + const LocalQueueItem* getQueueItem(int index) const; + const LocalQueueItem* getCurrentQueueItem() const; + const LocalQueueItem* getPreviousQueueItem() const; + const LocalQueueItem* getNextQueueItem() const; + bool canNavigatePrevious() const { return _queueCount > 0 && _currentQueueIndex > 0; } + bool canNavigateNext() const { return _queueCount > 0 && _currentQueueIndex < _queueCount - 1; } + + // Optimistic navigation (returns true if navigation was possible) + bool navigateToPrevious(); + bool navigateToNext(); + void setCurrentQueueIndex(int index); + + // Pending navigation state (for reconciliation with backend) + bool hasPendingNavigation() const { return _pendingNavigation; } + void clearPendingNavigation() { _pendingNavigation = false; } + const char* getPendingQueueItemUuid() const; + void setPendingNavigation(bool pending) { _pendingNavigation = pending; } + // Refresh all sections void refresh(); @@ -176,6 +270,19 @@ class LilyGoDisplay { std::vector _history; static const int MAX_HISTORY_ITEMS = 5; + // Local queue storage + LocalQueueItem _queueItems[MAX_QUEUE_SIZE]; + int _queueCount; + int _currentQueueIndex; + bool _pendingNavigation; + + // Navigation state (from backend) + QueueNavigationItem _prevClimb; + QueueNavigationItem _nextClimb; + int _queueIndex; + int _queueTotal; + bool _hasNavigation; + // QR code data // QR Version 6: size = 6*4+17 = 41 modules per side // Buffer = ((41*41)+7)/8 = 211 bytes @@ -187,9 +294,12 @@ class LilyGoDisplay { // Internal drawing methods void drawStatusBar(); + void drawPrevClimbIndicator(); void drawCurrentClimb(); void drawQRCode(); + void drawNextClimbIndicator(); void drawHistory(); + void drawButtonHints(); void setQRCodeUrl(const char* url); // Utility diff --git a/embedded/projects/climb-queue-display/src/main.cpp b/embedded/projects/climb-queue-display/src/main.cpp index be3efee2..7f34898d 100644 --- a/embedded/projects/climb-queue-display/src/main.cpp +++ b/embedded/projects/climb-queue-display/src/main.cpp @@ -42,6 +42,7 @@ String currentClimbUuid = ""; String currentClimbName = ""; String currentGrade = ""; String currentGradeColor = ""; +String currentQueueItemUuid = ""; // Queue item UUID for navigation bool hasCurrentClimb = false; // ============================================ @@ -51,7 +52,11 @@ bool hasCurrentClimb = false; void onWiFiStateChange(WiFiConnectionState state); void onGraphQLStateChange(GraphQLConnectionState state); void onGraphQLMessage(JsonDocument& doc); +void onQueueSync(const ControllerQueueSyncData& data); void handleLedUpdate(JsonObject& data); +void navigatePrevious(); +void navigateNext(); +void sendNavigationMutation(const char* queueItemUuid); #if !BLE_PROXY_DISABLED void onBleConnect(bool connected, const char* deviceName); @@ -93,15 +98,10 @@ void setup() { WiFiMgr.begin(); WiFiMgr.setStateCallback(onWiFiStateChange); - // Try to connect to saved WiFi or start AP mode for setup + // Try to connect to saved WiFi if (!WiFiMgr.connectSaved()) { - Logger.logln("No saved WiFi credentials - starting AP mode"); - String apName = "Boardsesh-Queue-" + String((uint32_t)(ESP.getEfuseMac() & 0xFFFF), HEX); - if (WiFiMgr.startAP(apName.c_str())) { - Display.showConfigPortal(apName.c_str(), WiFiMgr.getAPIP().c_str()); - } else { - Display.showError("AP Mode Failed"); - } + Logger.logln("No saved WiFi credentials - configure via web server"); + Display.showError("Configure WiFi"); } // Initialize web config server @@ -168,32 +168,69 @@ void loop() { } #endif - // Handle button presses + // Handle button presses with debouncing static bool lastButton1 = HIGH; + static bool lastButton2 = HIGH; static unsigned long button1PressTime = 0; + static unsigned long button2PressTime = 0; + static bool button1LongPressTriggered = false; + static const unsigned long DEBOUNCE_MS = 50; + static const unsigned long LONG_PRESS_MS = 3000; + static const unsigned long SHORT_PRESS_MAX_MS = 500; + bool button1 = digitalRead(BUTTON_1_PIN); + bool button2 = digitalRead(BUTTON_2_PIN); + unsigned long now = millis(); - // Button 1 - long press (3 sec) to reset config + // Button 1: Short press = previous, Long press (3s) = reset config if (button1 == LOW && lastButton1 == HIGH) { - button1PressTime = millis(); - Logger.logln("Button 1 pressed - hold 3s to reset config"); - } - if (button1 == LOW && button1PressTime > 0 && millis() - button1PressTime > 3000) { - Logger.logln("Resetting configuration..."); - Display.showError("Resetting..."); - - Config.setString("wifi_ssid", ""); - Config.setString("wifi_pass", ""); - Config.setString("api_key", ""); - Config.setString("session_id", ""); + // Button just pressed + button1PressTime = now; + button1LongPressTriggered = false; + } else if (button1 == LOW && button1PressTime > 0) { + // Button held down - check for long press + if (!button1LongPressTriggered && now - button1PressTime > LONG_PRESS_MS) { + Logger.logln("Button 1 long press - resetting configuration..."); + Display.showError("Resetting..."); + button1LongPressTriggered = true; + + Config.setString("wifi_ssid", ""); + Config.setString("wifi_pass", ""); + Config.setString("api_key", ""); + Config.setString("session_id", ""); - delay(1000); - ESP.restart(); - } - if (button1 == HIGH) { + delay(1000); + ESP.restart(); + } + } else if (button1 == HIGH && lastButton1 == LOW) { + // Button just released - check for short press (navigate previous) + if (!button1LongPressTriggered && button1PressTime > 0) { + unsigned long pressDuration = now - button1PressTime; + if (pressDuration > DEBOUNCE_MS && pressDuration < SHORT_PRESS_MAX_MS) { + Logger.logln("Button 1 short press - navigate previous"); + navigatePrevious(); + } + } button1PressTime = 0; } lastButton1 = button1; + + // Button 2: Short press = next + if (button2 == LOW && lastButton2 == HIGH) { + // Button just pressed + button2PressTime = now; + } else if (button2 == HIGH && lastButton2 == LOW) { + // Button just released - check for short press (navigate next) + if (button2PressTime > 0) { + unsigned long pressDuration = now - button2PressTime; + if (pressDuration > DEBOUNCE_MS && pressDuration < SHORT_PRESS_MAX_MS) { + Logger.logln("Button 2 short press - navigate next"); + navigateNext(); + } + } + button2PressTime = 0; + } + lastButton2 = button2; } // ============================================ @@ -207,11 +244,6 @@ void onWiFiStateChange(WiFiConnectionState state) { wifiConnected = true; Display.setWiFiStatus(true); - // Stop AP mode if it was active - if (WiFiMgr.isAPMode()) { - WiFiMgr.stopAP(); - } - // Get backend config String host = Config.getString("backend_host", DEFAULT_BACKEND_HOST); int port = Config.getInt("backend_port", DEFAULT_BACKEND_PORT); @@ -236,6 +268,7 @@ void onWiFiStateChange(WiFiConnectionState state) { GraphQL.setStateCallback(onGraphQLStateChange); GraphQL.setMessageCallback(onGraphQLMessage); + GraphQL.setQueueSyncCallback(onQueueSync); GraphQL.begin(host.c_str(), port, path.c_str(), apiKey.c_str()); break; } @@ -257,10 +290,6 @@ void onWiFiStateChange(WiFiConnectionState state) { Logger.logln("WiFi connection failed"); Display.showError("WiFi failed"); break; - - case WiFiConnectionState::AP_MODE: - Logger.logln("WiFi AP mode active: %s", WiFiMgr.getAPIP().c_str()); - break; } } @@ -286,8 +315,11 @@ void onGraphQLStateChange(GraphQLConnectionState state) { GraphQL.subscribe("controller-events", "subscription ControllerEvents($sessionId: ID!) { " "controllerEvents(sessionId: $sessionId) { " - "... on LedUpdate { __typename commands { position r g b } climbUuid climbName " - "climbGrade boardPath angle } " + "... on LedUpdate { __typename commands { position r g b } queueItemUuid climbUuid climbName " + "climbGrade gradeColor boardPath angle " + "navigation { previousClimbs { name grade gradeColor } " + "nextClimb { name grade gradeColor } currentIndex totalCount } } " + "... on ControllerQueueSync { __typename queue { uuid climbUuid name grade gradeColor } currentIndex } " "... on ControllerPing { __typename timestamp } " "} }", variables.c_str()); @@ -332,8 +364,49 @@ void onGraphQLMessage(JsonDocument& doc) { } } +// ============================================ +// Queue Sync Callback +// ============================================ + +void onQueueSync(const ControllerQueueSyncData& data) { + Logger.logln("Queue sync: %d items, currentIndex: %d", data.count, data.currentIndex); + + // Convert to LocalQueueItem array + LocalQueueItem items[ControllerQueueSyncData::MAX_ITEMS]; + for (int i = 0; i < data.count && i < MAX_QUEUE_SIZE; i++) { + strncpy(items[i].uuid, data.items[i].uuid, sizeof(items[i].uuid) - 1); + items[i].uuid[sizeof(items[i].uuid) - 1] = '\0'; + + strncpy(items[i].climbUuid, data.items[i].climbUuid, sizeof(items[i].climbUuid) - 1); + items[i].climbUuid[sizeof(items[i].climbUuid) - 1] = '\0'; + + strncpy(items[i].name, data.items[i].name, sizeof(items[i].name) - 1); + items[i].name[sizeof(items[i].name) - 1] = '\0'; + + strncpy(items[i].grade, data.items[i].grade, sizeof(items[i].grade) - 1); + items[i].grade[sizeof(items[i].grade) - 1] = '\0'; + + // Convert hex color to RGB565 if provided + if (data.items[i].gradeColor[0] == '#') { + items[i].gradeColorRgb = Display.getDisplay().color565( + strtol(String(data.items[i].gradeColor).substring(1, 3).c_str(), NULL, 16), + strtol(String(data.items[i].gradeColor).substring(3, 5).c_str(), NULL, 16), + strtol(String(data.items[i].gradeColor).substring(5, 7).c_str(), NULL, 16)); + } else { + items[i].gradeColorRgb = 0xFFFF; // White default + } + } + + // Update display queue state + Display.setQueueFromSync(items, data.count, data.currentIndex); + + Logger.logln("Queue sync complete: stored %d items, index %d", Display.getQueueCount(), + Display.getCurrentQueueIndex()); +} + void handleLedUpdate(JsonObject& data) { JsonArray commands = data["commands"]; + const char* queueItemUuid = data["queueItemUuid"]; const char* climbUuid = data["climbUuid"]; const char* climbName = data["climbName"]; const char* climbGrade = data["climbGrade"]; @@ -341,8 +414,23 @@ void handleLedUpdate(JsonObject& data) { int angle = data["angle"] | 0; int count = commands.isNull() ? 0 : commands.size(); - Logger.logln("LED Update: %s [%s] @ %d degrees (%d holds)", climbName ? climbName : "(none)", - climbGrade ? climbGrade : "?", angle, count); + Logger.logln("LED Update: %s [%s] @ %d degrees (%d holds), queueItemUuid: %s", climbName ? climbName : "(none)", + climbGrade ? climbGrade : "?", angle, count, queueItemUuid ? queueItemUuid : "(none)"); + + // Check if this confirms a pending navigation + if (Display.hasPendingNavigation() && queueItemUuid) { + const char* pendingUuid = Display.getPendingQueueItemUuid(); + if (pendingUuid && strcmp(queueItemUuid, pendingUuid) == 0) { + Logger.logln("LED Update confirms pending navigation to %s", queueItemUuid); + Display.clearPendingNavigation(); + // Display is already showing this climb from optimistic update - just update LEDs + } else { + Logger.logln("LED Update conflicts with pending navigation (expected %s, got %s)", pendingUuid, + queueItemUuid); + Display.clearPendingNavigation(); + // Fall through to update display with actual climb + } + } // Handle clear command if (commands.isNull() || count == 0) { @@ -351,6 +439,7 @@ void handleLedUpdate(JsonObject& data) { } hasCurrentClimb = false; + currentQueueItemUuid = ""; currentClimbUuid = ""; currentClimbName = ""; currentGrade = ""; @@ -408,14 +497,61 @@ void handleLedUpdate(JsonObject& data) { } // Update state + currentQueueItemUuid = queueItemUuid ? queueItemUuid : ""; currentClimbUuid = climbUuid ? climbUuid : ""; currentClimbName = climbName ? climbName : ""; currentGrade = climbGrade ? climbGrade : ""; - currentGradeColor = ""; // gradeColor not available from schema + + // Parse gradeColor if present + const char* gradeColor = data["gradeColor"]; + currentGradeColor = gradeColor ? gradeColor : ""; hasCurrentClimb = true; - // Update display (pass empty gradeColor - display will use default) - Display.showClimb(climbName, climbGrade, "", angle, climbUuid, boardType.c_str()); + // Sync local queue index with backend if we have queueItemUuid + if (queueItemUuid && Display.getQueueCount() > 0) { + // Find this item in our local queue and update index + for (int i = 0; i < Display.getQueueCount(); i++) { + const LocalQueueItem* item = Display.getQueueItem(i); + if (item && strcmp(item->uuid, queueItemUuid) == 0) { + Display.setCurrentQueueIndex(i); + Logger.logln("LED Update: Synced local queue index to %d", i); + break; + } + } + } + + // Parse navigation context if present + if (data["navigation"].is()) { + JsonObject nav = data["navigation"]; + int currentIndex = nav["currentIndex"] | -1; + int totalCount = nav["totalCount"] | 0; + + // Parse previous climb (first in previousClimbs array = immediate previous) + QueueNavigationItem prevClimb; + if (nav["previousClimbs"].is()) { + JsonArray prevArray = nav["previousClimbs"]; + if (prevArray.size() > 0) { + JsonObject prev = prevArray[0]; + prevClimb = QueueNavigationItem(prev["name"] | "", prev["grade"] | "", prev["gradeColor"] | ""); + } + } + + // Parse next climb + QueueNavigationItem nextClimb; + if (nav["nextClimb"].is()) { + JsonObject next = nav["nextClimb"]; + nextClimb = QueueNavigationItem(next["name"] | "", next["grade"] | "", next["gradeColor"] | ""); + } + + Display.setNavigationContext(prevClimb, nextClimb, currentIndex, totalCount); + Logger.logln("Navigation: index %d/%d, prev: %s, next: %s", currentIndex + 1, totalCount, + prevClimb.isValid ? "yes" : "no", nextClimb.isValid ? "yes" : "no"); + } else { + Display.clearNavigationContext(); + } + + // Update display with gradeColor + Display.showClimb(climbName, climbGrade, currentGradeColor.c_str(), angle, climbUuid, boardType.c_str()); #if !BLE_PROXY_DISABLED // Forward to BLE board @@ -466,3 +602,92 @@ void forwardToBoard() { } } #endif + +// ============================================ +// Queue Navigation Functions (with optimistic updates) +// ============================================ + +void sendNavigationMutation(const char* queueItemUuid) { + String sessionId = Config.getString("session_id"); + if (sessionId.length() == 0) { + Logger.logln("Navigation: No session ID configured"); + return; + } + + // Use queueItemUuid for direct navigation (most reliable) + String vars = "{\"sessionId\":\"" + sessionId + "\",\"direction\":\"next\""; // direction is fallback + vars += ",\"queueItemUuid\":\"" + String(queueItemUuid) + "\""; + vars += "}"; + + GraphQL.sendMutation("nav-direct", + "mutation NavDirect($sessionId: ID!, $direction: String!, $queueItemUuid: String) { " + "navigateQueue(sessionId: $sessionId, direction: $direction, queueItemUuid: $queueItemUuid) { " + "uuid climb { name difficulty } } }", + vars.c_str()); + + Logger.logln("Navigation: Sent navigate request to queueItemUuid: %s", queueItemUuid); +} + +void navigatePrevious() { + if (!backendConnected) { + Logger.logln("Navigation: Cannot navigate - not connected to backend"); + return; + } + + // Check if we have local queue state + if (Display.getQueueCount() == 0) { + Logger.logln("Navigation: No queue state - cannot navigate"); + return; + } + + if (!Display.canNavigatePrevious()) { + Logger.logln("Navigation: Already at start of queue"); + return; + } + + // Optimistic update - immediately show previous climb + if (Display.navigateToPrevious()) { + const LocalQueueItem* newCurrent = Display.getCurrentQueueItem(); + if (newCurrent) { + Logger.logln("Navigation: Optimistic update to previous - %s (uuid: %s)", newCurrent->name, newCurrent->uuid); + + // Update display immediately (optimistic) + Display.showClimb(newCurrent->name, newCurrent->grade, "", 0, newCurrent->climbUuid, boardType.c_str()); + + // Send mutation with queueItemUuid for backend sync + sendNavigationMutation(newCurrent->uuid); + } + } +} + +void navigateNext() { + if (!backendConnected) { + Logger.logln("Navigation: Cannot navigate - not connected to backend"); + return; + } + + // Check if we have local queue state + if (Display.getQueueCount() == 0) { + Logger.logln("Navigation: No queue state - cannot navigate"); + return; + } + + if (!Display.canNavigateNext()) { + Logger.logln("Navigation: Already at end of queue"); + return; + } + + // Optimistic update - immediately show next climb + if (Display.navigateToNext()) { + const LocalQueueItem* newCurrent = Display.getCurrentQueueItem(); + if (newCurrent) { + Logger.logln("Navigation: Optimistic update to next - %s (uuid: %s)", newCurrent->name, newCurrent->uuid); + + // Update display immediately (optimistic) + Display.showClimb(newCurrent->name, newCurrent->grade, "", 0, newCurrent->climbUuid, boardType.c_str()); + + // Send mutation with queueItemUuid for backend sync + sendNavigationMutation(newCurrent->uuid); + } + } +} diff --git a/packages/backend/src/graphql/resolvers/controller/mutations.ts b/packages/backend/src/graphql/resolvers/controller/mutations.ts index 6853861d..d5c6d80a 100644 --- a/packages/backend/src/graphql/resolvers/controller/mutations.ts +++ b/packages/backend/src/graphql/resolvers/controller/mutations.ts @@ -9,6 +9,7 @@ import type { SendDeviceLogsInput, SendDeviceLogsResponse, } from '@boardsesh/shared-schema'; +import { findClimbIndex } from './navigation-helpers'; import { db } from '../../../db/client'; import { esp32Controllers } from '@boardsesh/db/schema/app'; import { eq, and } from 'drizzle-orm'; @@ -342,6 +343,127 @@ export const controllerMutations = { return true; }, + /** + * Navigate to the previous or next climb in the queue + * Used by ESP32 controller to browse the queue via hardware buttons + * + * @param sessionId - Session ID to navigate within + * @param direction - "next" or "previous" (fallback if queueItemUuid not provided) + * @param currentClimbUuid - DEPRECATED: UUID of climb currently displayed (unreliable with duplicates) + * @param queueItemUuid - Directly navigate to this queue item (preferred, unique per position) + * @returns The new current climb, or null if navigation failed + */ + navigateQueue: async ( + _: unknown, + { sessionId, direction, currentClimbUuid, queueItemUuid }: { sessionId: string; direction: string; currentClimbUuid?: string; queueItemUuid?: string }, + ctx: ConnectionContext + ): Promise => { + applyRateLimit(ctx, 30); + + // Verify controller is authenticated and authorized for this session + const { controllerId } = await requireControllerAuthorizedForSession(ctx, sessionId); + + // Get current queue state + const currentState = await roomManager.getQueueState(sessionId); + const { queue, currentClimbQueueItem } = currentState; + + if (queue.length === 0) { + console.log(`[Controller] Navigate: queue is empty`); + return null; + } + + let newCurrentClimb: ClimbQueueItem; + let newIndex: number; + + // If queueItemUuid is provided, navigate directly to that item (preferred method) + if (queueItemUuid) { + newIndex = queue.findIndex((item) => item.uuid === queueItemUuid); + if (newIndex === -1) { + console.log(`[Controller] Navigate: queueItemUuid ${queueItemUuid} not found in queue`); + // Fall back to direction-based navigation + } else { + newCurrentClimb = queue[newIndex]; + console.log(`[Controller] Navigate: direct to queueItemUuid ${queueItemUuid}, index ${newIndex}, climb: ${newCurrentClimb.climb.name}`); + + // Update queue state + const { sequence } = await roomManager.updateQueueState(sessionId, queue, newCurrentClimb); + + // Publish CurrentClimbChanged event + pubsub.publishQueueEvent(sessionId, { + __typename: 'CurrentClimbChanged', + sequence, + item: newCurrentClimb, + clientId: null, + correlationId: null, + }); + + return newCurrentClimb; + } + } + + // Validate direction for fallback navigation + if (direction !== 'next' && direction !== 'previous') { + throw new Error('Invalid direction. Must be "next" or "previous".'); + } + + // Find current position in queue using queue item UUID or climb UUID + const referenceUuid = currentClimbUuid || currentClimbQueueItem?.uuid; + const currentIndex = findClimbIndex(queue, referenceUuid); + + console.log(`[Controller] Navigate ${direction}: using referenceUuid=${referenceUuid} (from ESP32: ${!!currentClimbUuid}), found at index ${currentIndex}`); + + // Calculate new index + if (direction === 'next') { + // Move forward in queue + if (currentIndex === -1) { + // No current climb, start at beginning + newIndex = 0; + } else if (currentIndex >= queue.length - 1) { + // Already at end, stay there + console.log(`[Controller] Navigate next: already at end of queue`); + return queue[currentIndex]; // Return the climb at current index + } else { + newIndex = currentIndex + 1; + } + } else { + // Move backward in queue (previous) + if (currentIndex === -1) { + // No current climb, start at end + newIndex = queue.length - 1; + } else if (currentIndex <= 0) { + // Already at beginning, stay there + console.log(`[Controller] Navigate previous: already at start of queue`); + return queue[currentIndex]; // Return the climb at current index + } else { + newIndex = currentIndex - 1; + } + } + + // Get the new current climb + newCurrentClimb = queue[newIndex]; + + console.log( + `[Controller] Navigate ${direction}: index ${currentIndex} -> ${newIndex}, climb: ${newCurrentClimb.climb.name} (queueItem uuid: ${newCurrentClimb.uuid})` + ); + console.log(`[Controller] Queue has ${queue.length} items, updating current to index ${newIndex}`); + + // Update queue state (keep the same queue, just change current climb) + const { sequence } = await roomManager.updateQueueState(sessionId, queue, newCurrentClimb); + + // Publish CurrentClimbChanged event WITHOUT clientId + // Unlike setClimbFromLedPositions (where we skip because ESP32 already has LEDs from BLE), + // navigation NEEDS to send LedUpdate back to the ESP32 since it's moving to a different climb + pubsub.publishQueueEvent(sessionId, { + __typename: 'CurrentClimbChanged', + sequence, + item: newCurrentClimb, + clientId: null, // Don't skip - ESP32 needs the new climb data + correlationId: null, + }); + + return newCurrentClimb; + }, + /** * Receive device logs from ESP32 controller and forward to Axiom * Uses API key authentication via connectionParams diff --git a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts index 05b89f20..655e2fdc 100644 --- a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts +++ b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts @@ -1,4 +1,4 @@ -import type { ConnectionContext, ControllerEvent, LedCommand, BoardName } from '@boardsesh/shared-schema'; +import type { ConnectionContext, ControllerEvent, LedUpdate, LedCommand, BoardName, QueueNavigationContext, ControllerQueueItem, ControllerQueueSync, ClimbQueueItem } from '@boardsesh/shared-schema'; import { db } from '../../../db/client'; import { esp32Controllers } from '@boardsesh/db/schema/app'; import { eq } from 'drizzle-orm'; @@ -7,6 +7,8 @@ import { roomManager } from '../../../services/room-manager'; import { createAsyncIterator } from '../shared/async-iterators'; import { getLedPlacements } from '../../../db/queries/util/led-placements-data'; import { requireControllerAuthorizedForSession } from '../shared/helpers'; +import { getGradeColor } from './grade-colors'; +import { buildNavigationContext, findClimbIndex } from './navigation-helpers'; // LED color mapping for hold states (matches web app colors) const HOLD_STATE_COLORS: Record = { @@ -17,6 +19,34 @@ const HOLD_STATE_COLORS: Record = { OFF: { r: 0, g: 0, b: 0 }, // Off }; +/** + * Build a minimal ControllerQueueItem from a full ClimbQueueItem + */ +function buildControllerQueueItem(item: ClimbQueueItem): ControllerQueueItem { + return { + uuid: item.uuid, + climbUuid: item.climb.uuid, + name: item.climb.name, + grade: item.climb.difficulty, + gradeColor: getGradeColor(item.climb.difficulty), + }; +} + +/** + * Build a ControllerQueueSync event from current queue state + */ +function buildControllerQueueSync(queue: ClimbQueueItem[], currentItemUuid: string | undefined): ControllerQueueSync { + const currentIndex = currentItemUuid + ? queue.findIndex((item) => item.uuid === currentItemUuid) + : -1; + + return { + __typename: 'ControllerQueueSync', + queue: queue.map(buildControllerQueueItem), + currentIndex, + }; +} + /** * Convert a climb's litUpHoldsMap to LED commands using LED placements data */ @@ -94,13 +124,84 @@ export const controllerSubscriptions = { `[Controller] Controller ${controller.id} subscribed to session ${sessionId} (boardPath: ${boardPath})` ); + // Helper to build LedUpdate with navigation context + const buildLedUpdateWithNavigation = async ( + climb: { uuid: string; name: string; difficulty: string; angle: number; litUpHoldsMap: Record } | null | undefined, + currentItemUuid?: string + ): Promise => { + // Get LED placements for this controller's configuration + const ledPlacements = getLedPlacements( + controller.boardName as BoardName, + controller.layoutId, + controller.sizeId + ); + + if (!climb) { + // No current climb - clear LEDs + return { + __typename: 'LedUpdate', + commands: [], + boardPath, + navigation: { + previousClimbs: [], + nextClimb: null, + currentIndex: -1, + totalCount: 0, + }, + }; + } + + const commands = climbToLedCommands(climb, ledPlacements); + + // Get queue state for navigation context + const queueState = await roomManager.getQueueState(sessionId); + console.log(`[Controller] buildLedUpdate: queueState has ${queueState.queue.length} items, currentClimb: ${queueState.currentClimbQueueItem?.climb?.name} (uuid: ${queueState.currentClimbQueueItem?.uuid})`); + console.log(`[Controller] buildLedUpdate: looking for currentItemUuid: ${currentItemUuid}`); + const currentIndex = findClimbIndex(queueState.queue, currentItemUuid); + console.log(`[Controller] buildLedUpdate: found at index ${currentIndex}`); + const navigation = buildNavigationContext(queueState.queue, currentIndex); + + return { + __typename: 'LedUpdate', + commands, + queueItemUuid: currentItemUuid, + climbUuid: climb.uuid, + climbName: climb.name, + climbGrade: climb.difficulty, + gradeColor: getGradeColor(climb.difficulty), + boardPath, + angle: climb.angle, + navigation, + }; + }; + // Create subscription to queue events const asyncIterator = await createAsyncIterator((push) => { // Subscribe to queue updates for this session return pubsub.subscribeQueue(sessionId, (queueEvent) => { console.log(`[Controller] Received queue event: ${queueEvent.__typename}`); - // Only process current climb changes + // Handle queue modification events - send ControllerQueueSync + if (queueEvent.__typename === 'QueueItemAdded' || + queueEvent.__typename === 'QueueItemRemoved' || + queueEvent.__typename === 'QueueReordered') { + (async () => { + try { + const queueState = await roomManager.getQueueState(sessionId); + const queueSync = buildControllerQueueSync( + queueState.queue, + queueState.currentClimbQueueItem?.uuid + ); + console.log(`[Controller] Sending QueueSync: ${queueSync.queue.length} items, currentIndex: ${queueSync.currentIndex}`); + push(queueSync); + } catch (error) { + console.error(`[Controller] Error building queue sync:`, error); + } + })(); + return; + } + + // Handle current climb changes and full sync if (queueEvent.__typename === 'CurrentClimbChanged' || queueEvent.__typename === 'FullSync') { // Skip LedUpdate if this controller initiated the change // (LEDs are already set from BLE data, this would be redundant) @@ -112,70 +213,54 @@ export const controllerSubscriptions = { } } - const climb = queueEvent.__typename === 'CurrentClimbChanged' - ? queueEvent.item?.climb - : queueEvent.state.currentClimbQueueItem?.climb; - - if (climb) { - console.log(`[Controller] Sending LED update for climb: ${climb.name}`); - - // Get LED placements for this controller's configuration - const ledPlacements = getLedPlacements( - controller.boardName as BoardName, - controller.layoutId, - controller.sizeId - ); - const commands = climbToLedCommands(climb, ledPlacements); - - const ledUpdate: ControllerEvent = { - __typename: 'LedUpdate', - commands, - climbUuid: climb.uuid, - climbName: climb.name, - climbGrade: climb.difficulty, - boardPath, - angle: climb.angle, - }; - push(ledUpdate); - } else { - console.log(`[Controller] Clearing LEDs (no current climb)`); - // No current climb - clear LEDs - const ledUpdate: ControllerEvent = { - __typename: 'LedUpdate', - commands: [], - boardPath, - }; - push(ledUpdate); - } + const currentItem = queueEvent.__typename === 'CurrentClimbChanged' + ? queueEvent.item + : queueEvent.state.currentClimbQueueItem; + const climb = currentItem?.climb; + + // Handle async work with proper error handling + (async () => { + try { + if (climb) { + console.log(`[Controller] Sending LED update for climb: ${climb.name} (uuid: ${currentItem?.uuid})`); + const ledUpdate = await buildLedUpdateWithNavigation(climb, currentItem?.uuid); + console.log(`[Controller] LED update navigation: index ${ledUpdate.navigation?.currentIndex}/${ledUpdate.navigation?.totalCount}, climb: ${ledUpdate.climbName}`); + console.log(`[Controller] Pushing LED update to subscription`); + push(ledUpdate); + } else { + console.log(`[Controller] Clearing LEDs (no current climb)`); + const ledUpdate = await buildLedUpdateWithNavigation(null); + push(ledUpdate); + } + } catch (error) { + console.error(`[Controller] Error building LED update:`, error); + } + })(); } }); }); - // Send initial state - const queueState = await roomManager.getQueueState(sessionId); - if (queueState.currentClimbQueueItem?.climb) { - const climb = queueState.currentClimbQueueItem.climb; - const ledPlacements = getLedPlacements( - controller.boardName as BoardName, - controller.layoutId, - controller.sizeId - ); - const commands = climbToLedCommands(climb, ledPlacements); - yield { - controllerEvents: { - __typename: 'LedUpdate', - commands, - climbUuid: climb.uuid, - climbName: climb.name, - climbGrade: climb.difficulty, - boardPath, - angle: climb.angle, - }, - }; - } + // Send initial queue sync first (so ESP32 has queue state before LED update) + const initialQueueState = await roomManager.getQueueState(sessionId); + const initialQueueSync = buildControllerQueueSync( + initialQueueState.queue, + initialQueueState.currentClimbQueueItem?.uuid + ); + console.log(`[Controller] Sending initial QueueSync: ${initialQueueSync.queue.length} items, currentIndex: ${initialQueueSync.currentIndex}`); + yield { controllerEvents: initialQueueSync }; + + // Send initial LED state + const initialClimb = initialQueueState.currentClimbQueueItem?.climb; + const initialLedUpdate = await buildLedUpdateWithNavigation( + initialClimb, + initialQueueState.currentClimbQueueItem?.uuid + ); + yield { controllerEvents: initialLedUpdate }; // Yield events from subscription + console.log(`[Controller] Starting to yield events for controller ${controller.id}`); for await (const event of asyncIterator) { + console.log(`[Controller] Yielding event to client: ${event.__typename}`); // Update lastSeenAt periodically (on each event) await db .update(esp32Controllers) @@ -184,6 +269,7 @@ export const controllerSubscriptions = { yield { controllerEvents: event }; } + console.log(`[Controller] Subscription loop ended for controller ${controller.id}`); }, }, }; @@ -199,6 +285,9 @@ export const controllerEventResolver = { if ('timestamp' in obj) { return 'ControllerPing'; } + if ('queue' in obj && 'currentIndex' in obj) { + return 'ControllerQueueSync'; + } return null; }, }; diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 30b7a33d..5f664034 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -1261,6 +1261,12 @@ export const typeDefs = /* GraphQL */ ` frames: String positions: [LedCommandInput!] ): ClimbMatchResult! + # Navigate to previous or next climb in the queue + # queueItemUuid: Directly navigate to this queue item (preferred) + # direction: "next" or "previous" (fallback if queueItemUuid not provided) + # currentClimbUuid: DEPRECATED - UUID of climb currently displayed (unreliable with duplicates) + # Requires controller API key in connectionParams + navigateQueue(sessionId: ID!, direction: String!, currentClimbUuid: String, queueItemUuid: String): ClimbQueueItem # ESP32 heartbeat to update lastSeenAt - uses API key auth via connectionParams controllerHeartbeat(sessionId: ID!): Boolean! # Authorize a controller for a specific session (requires user auth, auto-called on joinSession) @@ -1428,14 +1434,37 @@ export const typeDefs = /* GraphQL */ ` role: Int } + # Minimal climb info for ESP32 navigation display + type QueueNavigationItem { + name: String! + grade: String! + gradeColor: String! + } + + # Navigation context sent with LED updates + type QueueNavigationContext { + "Previous climbs in queue (up to 3, most recent first)" + previousClimbs: [QueueNavigationItem!]! + "Next climb in queue (null if at end)" + nextClimb: QueueNavigationItem + "Current position in queue (0-indexed)" + currentIndex: Int! + "Total number of items in queue" + totalCount: Int! + } + # LED update event sent to controller type LedUpdate { commands: [LedCommand!]! + "Queue item UUID (for reconciling optimistic UI)" + queueItemUuid: String climbUuid: String climbName: String climbGrade: String + gradeColor: String boardPath: String angle: Int + navigation: QueueNavigationContext } # Ping event to keep controller connection alive @@ -1443,8 +1472,30 @@ export const typeDefs = /* GraphQL */ ` timestamp: String! } + # Minimal queue item for controller display (subset of ClimbQueueItem) + type ControllerQueueItem { + "Queue item UUID (unique per queue position, used for navigation)" + uuid: ID! + "Climb UUID (for display matching)" + climbUuid: ID! + "Climb name (truncated for display)" + name: String! + "Grade string" + grade: String! + "Grade color as hex string" + gradeColor: String! + } + + # Queue sync event sent to controller + type ControllerQueueSync { + "Complete queue state for controller" + queue: [ControllerQueueItem!]! + "Index of current climb in queue (-1 if none)" + currentIndex: Int! + } + # Union of events sent to controller - union ControllerEvent = LedUpdate | ControllerPing + union ControllerEvent = LedUpdate | ControllerPing | ControllerQueueSync # Controller info for management UI type ControllerInfo { diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 5a187cd0..61036d32 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -345,13 +345,33 @@ export type LedCommand = { role?: number; }; +// Minimal climb info for ESP32 navigation display +export type QueueNavigationItem = { + name: string; + grade: string; + gradeColor: string; +}; + +// Navigation context sent with LED updates +export type QueueNavigationContext = { + previousClimbs: QueueNavigationItem[]; + nextClimb: QueueNavigationItem | null; + currentIndex: number; + totalCount: number; +}; + // LED update event sent to controller export type LedUpdate = { __typename: 'LedUpdate'; commands: LedCommand[]; + queueItemUuid?: string; climbUuid?: string; climbName?: string; + climbGrade?: string; + gradeColor?: string; + boardPath?: string; angle?: number; + navigation?: QueueNavigationContext | null; }; // Ping event to keep controller connection alive @@ -360,8 +380,24 @@ export type ControllerPing = { timestamp: string; }; +// Minimal queue item for controller display +export type ControllerQueueItem = { + uuid: string; // Queue item UUID (for navigation) + climbUuid: string; // Climb UUID (for display/matching) + name: string; + grade: string; + gradeColor: string; +}; + +// Queue sync event sent to controller +export type ControllerQueueSync = { + __typename: 'ControllerQueueSync'; + queue: ControllerQueueItem[]; + currentIndex: number; +}; + // Union of events sent to controller -export type ControllerEvent = LedUpdate | ControllerPing; +export type ControllerEvent = LedUpdate | ControllerPing | ControllerQueueSync; // Controller info for management UI export type ControllerInfo = { From 02674b5ba0cfb19e0e9e65857e1412ecc877a51e Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Feb 2026 13:26:36 +1100 Subject: [PATCH 02/14] fix: Use MAC address for BLE disconnect decision after climb changes When a climb was loaded via Bluetooth from the official Kilter app, the ESP32 display wasn't receiving metadata updates because the backend was skipping LedUpdate events sent to the same controller that initiated them. This fix: - Adds clientId field to LedUpdate GraphQL type - Backend now always sends LedUpdate with clientId (MAC address of controller) - ESP32 compares incoming clientId with its own MAC address - If clientId matches own MAC: self-initiated change, keep BLE client connected - If clientId differs: web user took over, disconnect BLE client Also handles unknown climbs (drafts/unsynced) gracefully by displaying "Unknown Climb" with navigation context so users can navigate back. The MAC address is automatically detected - no manual configuration needed. Co-Authored-By: Claude Opus 4.5 --- .../src/graphql_ws_client.cpp | 81 ++-- .../graphql-ws-client/src/graphql_ws_client.h | 8 + .../projects/board-controller/src/main.cpp | 398 +++++++++++++++++- packages/backend/src/graphql/context.ts | 6 +- .../graphql/resolvers/controller/mutations.ts | 22 +- .../resolvers/controller/subscriptions.ts | 45 +- packages/backend/src/websocket/setup.ts | 14 +- packages/shared-schema/src/schema.ts | 2 + packages/shared-schema/src/types.ts | 4 + 9 files changed, 505 insertions(+), 75 deletions(-) diff --git a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp index f77f12c5..e2fab87e 100644 --- a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp +++ b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp @@ -1,5 +1,6 @@ #include "graphql_ws_client.h" +#include #include #include @@ -236,12 +237,18 @@ void GraphQLWSClient::sendConnectionInit() { JsonDocument doc; doc["type"] = "connection_init"; - // Include controller API key in connection payload if provided + // Include controller API key and MAC address in connection payload if (apiKey.length() > 0) { JsonObject payload = doc["payload"].to(); payload["controllerApiKey"] = apiKey; + // Send MAC address for clientId matching (so backend can use it as clientId) + payload["controllerMac"] = WiFi.macAddress(); } + // Store our own MAC for comparison with incoming clientId + deviceMac = WiFi.macAddress(); + Logger.logln("GraphQL: Device MAC for clientId comparison: %s", deviceMac.c_str()); + String msg; serializeJson(doc, msg); ws.sendTXT(msg); @@ -307,14 +314,24 @@ void GraphQLWSClient::handleMessage(uint8_t* payload, size_t length) { } void GraphQLWSClient::handleLedUpdate(JsonObject& data) { + // Check if this update was initiated by this controller (self-initiated from BLE) + // Compare incoming clientId with our device's MAC address + const char* updateClientId = data["clientId"]; + bool isSelfInitiated = updateClientId && deviceMac.length() > 0 && + (String(updateClientId) == deviceMac); + JsonArray commands = data["commands"]; if (commands.isNull() || commands.size() == 0) { - // Clear LEDs command - if BLE connected, this is likely web taking over - if (BLE.isConnected()) { + // Clear LEDs command + if (BLE.isConnected() && !isSelfInitiated) { + // Web user cleared - disconnect phone (BLE client), keep proxy Logger.logln("GraphQL: Web user cleared climb, disconnecting BLE client"); BLE.disconnectClient(); BLE.clearLastSentHash(); + } else if (isSelfInitiated) { + // Self-initiated (unknown climb from BLE) - keep phone connected + Logger.logln("GraphQL: Self-initiated clear/unknown climb, maintaining BLE client connection"); } LEDs.clear(); LEDs.show(); @@ -337,25 +354,23 @@ void GraphQLWSClient::handleLedUpdate(JsonObject& data) { i++; } - // Compute hash of incoming LED data + // Compute hash of incoming LED data for deduplication uint32_t incomingHash = computeLedHash(ledCommands, count); - // If BLE is connected, check if this is an echo of our own data or a web-initiated change + // Check if we should disconnect the BLE client (phone using official app) if (BLE.isConnected()) { - if (incomingHash == currentDisplayHash && currentDisplayHash != 0) { - // This is likely an echo from our own BLE send - ignore it - Logger.logln("GraphQL: Ignoring LedUpdate (hash %u matches current display, likely echo)", incomingHash); - delete[] ledCommands; - return; + if (isSelfInitiated) { + // Self-initiated from this controller's BLE - keep phone connected + Logger.logln("GraphQL: Self-initiated update, maintaining BLE client connection"); } else { - // Different LED data - web user is taking over + // Web user changed climb - disconnect phone (BLE client), keep proxy Logger.logln("GraphQL: Web user changed climb, disconnecting BLE client"); BLE.disconnectClient(); BLE.clearLastSentHash(); } } - // Update LEDs + // Always render LEDs (it's imperceptible to users) LEDs.setLeds(ledCommands, count); LEDs.show(); @@ -365,9 +380,9 @@ void GraphQLWSClient::handleLedUpdate(JsonObject& data) { // Log climb info if available const char* climbName = data["climbName"]; if (climbName) { - Logger.logln("GraphQL: Displaying climb: %s (%d LEDs)", climbName, count); + Logger.logln("GraphQL: Displaying climb: %s (%d LEDs, clientId: %s)", climbName, count, updateClientId ? updateClientId : "null"); } else { - Logger.logln("GraphQL: Updated %d LEDs", count); + Logger.logln("GraphQL: Updated %d LEDs (clientId: %s)", count, updateClientId ? updateClientId : "null"); } delete[] ledCommands; @@ -390,10 +405,15 @@ void GraphQLWSClient::handleQueueSync(JsonObject& data) { return; } - // Build the sync data structure - ControllerQueueSyncData syncData; - syncData.count = min(count, ControllerQueueSyncData::MAX_ITEMS); - syncData.currentIndex = currentIndex; + // Allocate on heap to avoid stack overflow (~19KB struct) + ControllerQueueSyncData* syncData = new ControllerQueueSyncData(); + if (!syncData) { + Logger.logln("GraphQL: Failed to allocate QueueSync data"); + return; + } + + syncData->count = min(count, ControllerQueueSyncData::MAX_ITEMS); + syncData->currentIndex = currentIndex; int i = 0; for (JsonObject item : queueArray) { @@ -406,26 +426,29 @@ void GraphQLWSClient::handleQueueSync(JsonObject& data) { const char* gradeColor = item["gradeColor"] | ""; // Copy with truncation - strncpy(syncData.items[i].uuid, uuid, sizeof(syncData.items[i].uuid) - 1); - syncData.items[i].uuid[sizeof(syncData.items[i].uuid) - 1] = '\0'; + strncpy(syncData->items[i].uuid, uuid, sizeof(syncData->items[i].uuid) - 1); + syncData->items[i].uuid[sizeof(syncData->items[i].uuid) - 1] = '\0'; - strncpy(syncData.items[i].climbUuid, climbUuid, sizeof(syncData.items[i].climbUuid) - 1); - syncData.items[i].climbUuid[sizeof(syncData.items[i].climbUuid) - 1] = '\0'; + strncpy(syncData->items[i].climbUuid, climbUuid, sizeof(syncData->items[i].climbUuid) - 1); + syncData->items[i].climbUuid[sizeof(syncData->items[i].climbUuid) - 1] = '\0'; - strncpy(syncData.items[i].name, name, sizeof(syncData.items[i].name) - 1); - syncData.items[i].name[sizeof(syncData.items[i].name) - 1] = '\0'; + strncpy(syncData->items[i].name, name, sizeof(syncData->items[i].name) - 1); + syncData->items[i].name[sizeof(syncData->items[i].name) - 1] = '\0'; - strncpy(syncData.items[i].grade, grade, sizeof(syncData.items[i].grade) - 1); - syncData.items[i].grade[sizeof(syncData.items[i].grade) - 1] = '\0'; + strncpy(syncData->items[i].grade, grade, sizeof(syncData->items[i].grade) - 1); + syncData->items[i].grade[sizeof(syncData->items[i].grade) - 1] = '\0'; - strncpy(syncData.items[i].gradeColor, gradeColor, sizeof(syncData.items[i].gradeColor) - 1); - syncData.items[i].gradeColor[sizeof(syncData.items[i].gradeColor) - 1] = '\0'; + strncpy(syncData->items[i].gradeColor, gradeColor, sizeof(syncData->items[i].gradeColor) - 1); + syncData->items[i].gradeColor[sizeof(syncData->items[i].gradeColor) - 1] = '\0'; i++; } // Call the callback - queueSyncCallback(syncData); + queueSyncCallback(*syncData); + + // Free heap memory + delete syncData; } void GraphQLWSClient::sendLedPositions(const LedCommand* commands, int count, int angle) { diff --git a/embedded/libs/graphql-ws-client/src/graphql_ws_client.h b/embedded/libs/graphql-ws-client/src/graphql_ws_client.h index 80d4d17d..a4bd114b 100644 --- a/embedded/libs/graphql-ws-client/src/graphql_ws_client.h +++ b/embedded/libs/graphql-ws-client/src/graphql_ws_client.h @@ -85,6 +85,12 @@ class GraphQLWSClient { // Get current display hash (for deduplication) uint32_t getCurrentDisplayHash() { return currentDisplayHash; } + // Set the controller ID for comparison with incoming clientId + void setControllerId(const String& id) { controllerId = id; } + + // Get the controller ID + const String& getControllerId() { return controllerId; } + private: WebSocketsClient ws; GraphQLConnectionState state; @@ -99,6 +105,8 @@ class GraphQLWSClient { bool useSSL; String sessionId; String subscriptionId; + String controllerId; // Controller ID for comparison with incoming clientId (deprecated, use deviceMac) + String deviceMac; // Device MAC address for clientId comparison (auto-detected) unsigned long lastPingTime; unsigned long lastPongTime; diff --git a/embedded/projects/board-controller/src/main.cpp b/embedded/projects/board-controller/src/main.cpp index d1f68ee8..bc81d7b9 100644 --- a/embedded/projects/board-controller/src/main.cpp +++ b/embedded/projects/board-controller/src/main.cpp @@ -30,6 +30,17 @@ bool wifiConnected = false; bool backendConnected = false; +#ifdef ENABLE_DISPLAY +// Queue/climb state for display +String currentQueueItemUuid = ""; +String currentGradeColor = ""; +String currentClimbUuid = ""; +String currentClimbName = ""; +String currentGrade = ""; +String boardType = "kilter"; +bool hasCurrentClimb = false; +#endif + // Forward declarations void onWiFiStateChange(WiFiConnectionState state); void onBLEConnect(bool connected); @@ -39,6 +50,10 @@ void onGraphQLStateChange(GraphQLConnectionState state); void onGraphQLMessage(JsonDocument& doc); #ifdef ENABLE_DISPLAY void handleLedUpdateExtended(JsonObject& data); +void onQueueSync(const ControllerQueueSyncData& data); +void navigatePrevious(); +void navigateNext(); +void sendNavigationMutation(const char* queueItemUuid); #endif void startupAnimation(); @@ -167,6 +182,72 @@ void loop() { // Process web server WebConfig.loop(); + +#ifdef ENABLE_DISPLAY + // Handle button presses with debouncing + static bool lastButton1 = HIGH; + static bool lastButton2 = HIGH; + static unsigned long button1PressTime = 0; + static unsigned long button2PressTime = 0; + static bool button1LongPressTriggered = false; + static const unsigned long DEBOUNCE_MS = 50; + static const unsigned long LONG_PRESS_MS = 3000; + static const unsigned long SHORT_PRESS_MAX_MS = 500; + + bool button1 = digitalRead(BUTTON_1_PIN); + bool button2 = digitalRead(BUTTON_2_PIN); + unsigned long now = millis(); + + // Button 1: Short press = previous, Long press (3s) = reset config + if (button1 == LOW && lastButton1 == HIGH) { + // Button just pressed + button1PressTime = now; + button1LongPressTriggered = false; + } else if (button1 == LOW && button1PressTime > 0) { + // Button held down - check for long press + if (!button1LongPressTriggered && now - button1PressTime > LONG_PRESS_MS) { + Logger.logln("Button 1 long press - resetting configuration..."); + Display.showError("Resetting..."); + button1LongPressTriggered = true; + + Config.setString("wifi_ssid", ""); + Config.setString("wifi_pass", ""); + Config.setString("api_key", ""); + Config.setString("session_id", ""); + + delay(1000); + ESP.restart(); + } + } else if (button1 == HIGH && lastButton1 == LOW) { + // Button just released - check for short press (navigate previous) + if (!button1LongPressTriggered && button1PressTime > 0) { + unsigned long pressDuration = now - button1PressTime; + if (pressDuration > DEBOUNCE_MS && pressDuration < SHORT_PRESS_MAX_MS) { + Logger.logln("Button 1 short press - navigate previous"); + navigatePrevious(); + } + } + button1PressTime = 0; + } + lastButton1 = button1; + + // Button 2: Short press = next + if (button2 == LOW && lastButton2 == HIGH) { + // Button just pressed + button2PressTime = now; + } else if (button2 == HIGH && lastButton2 == LOW) { + // Button just released - check for short press (navigate next) + if (button2PressTime > 0) { + unsigned long pressDuration = now - button2PressTime; + if (pressDuration > DEBOUNCE_MS && pressDuration < SHORT_PRESS_MAX_MS) { + Logger.logln("Button 2 short press - navigate next"); + navigateNext(); + } + } + button2PressTime = 0; + } + lastButton2 = button2; +#endif } void onWiFiStateChange(WiFiConnectionState state) { @@ -190,6 +271,9 @@ void onWiFiStateChange(WiFiConnectionState state) { break; } + // Note: Device MAC address is automatically used for clientId comparison + // (set during connection_init in graphql_ws_client.cpp) + Logger.logln("Connecting to backend: %s:%d%s", host.c_str(), port, path.c_str()); GraphQL.setStateCallback(onGraphQLStateChange); GraphQL.setMessageCallback(onGraphQLMessage); @@ -298,15 +382,26 @@ void onGraphQLStateChange(GraphQLConnectionState state) { // Build variables JSON (apiKey is in connectionParams, not here) String variables = "{\"sessionId\":\"" + sessionId + "\"}"; - // Subscribe to controller events (including new climbGrade and boardPath fields) + // Subscribe to controller events (full subscription with navigation and queue sync) + // clientId is included so ESP32 can decide whether to disconnect BLE client GraphQL.subscribe("controller-events", "subscription ControllerEvents($sessionId: ID!) { " "controllerEvents(sessionId: $sessionId) { " - "... on LedUpdate { __typename commands { position r g b } climbUuid climbName " - "climbGrade boardPath angle } " + "... on LedUpdate { __typename commands { position r g b } queueItemUuid climbUuid climbName " + "climbGrade gradeColor boardPath angle clientId " + "navigation { previousClimbs { name grade gradeColor } " + "nextClimb { name grade gradeColor } currentIndex totalCount } } " + "... on ControllerQueueSync { __typename queue { uuid climbUuid name grade gradeColor } currentIndex } " "... on ControllerPing { __typename timestamp } " "} }", variables.c_str()); + +#ifdef ENABLE_DISPLAY + // Set queue sync callback for display builds + GraphQL.setQueueSyncCallback(onQueueSync); + hasCurrentClimb = false; + Display.showNoClimb(); +#endif break; } @@ -341,22 +436,155 @@ void onGraphQLMessage(JsonDocument& doc) { } #ifdef ENABLE_DISPLAY +/** + * Handle queue sync event from backend + */ +void onQueueSync(const ControllerQueueSyncData& data) { + Logger.logln("Queue sync: %d items, currentIndex: %d", data.count, data.currentIndex); + + // Allocate on heap to avoid stack overflow (LocalQueueItem is ~88 bytes each) + int itemCount = min(data.count, MAX_QUEUE_SIZE); + LocalQueueItem* items = new LocalQueueItem[itemCount]; + if (!items) { + Logger.logln("Queue sync: Failed to allocate memory for %d items", itemCount); + return; + } + + for (int i = 0; i < itemCount; i++) { + strncpy(items[i].uuid, data.items[i].uuid, sizeof(items[i].uuid) - 1); + items[i].uuid[sizeof(items[i].uuid) - 1] = '\0'; + + strncpy(items[i].climbUuid, data.items[i].climbUuid, sizeof(items[i].climbUuid) - 1); + items[i].climbUuid[sizeof(items[i].climbUuid) - 1] = '\0'; + + strncpy(items[i].name, data.items[i].name, sizeof(items[i].name) - 1); + items[i].name[sizeof(items[i].name) - 1] = '\0'; + + strncpy(items[i].grade, data.items[i].grade, sizeof(items[i].grade) - 1); + items[i].grade[sizeof(items[i].grade) - 1] = '\0'; + + // Convert hex color to RGB565 if provided + if (data.items[i].gradeColor[0] == '#') { + items[i].gradeColorRgb = Display.getDisplay().color565( + strtol(String(data.items[i].gradeColor).substring(1, 3).c_str(), NULL, 16), + strtol(String(data.items[i].gradeColor).substring(3, 5).c_str(), NULL, 16), + strtol(String(data.items[i].gradeColor).substring(5, 7).c_str(), NULL, 16)); + } else { + items[i].gradeColorRgb = 0xFFFF; // White default + } + } + + // Update display queue state + Display.setQueueFromSync(items, itemCount, data.currentIndex); + + // Free heap memory + delete[] items; + + Logger.logln("Queue sync complete: stored %d items, index %d", Display.getQueueCount(), + Display.getCurrentQueueIndex()); +} + /** * Handle extended LedUpdate data for display * This is called in addition to GraphQL.handleLedUpdate (which handles LED control) */ void handleLedUpdateExtended(JsonObject& data) { - // Get boardPath for QR code generation and board type detection - const char* boardPath = data["boardPath"]; - - // Update display with climb info + const char* queueItemUuid = data["queueItemUuid"]; + const char* climbUuid = data["climbUuid"]; const char* climbName = data["climbName"]; const char* climbGrade = data["climbGrade"]; - const char* climbUuid = data["climbUuid"]; + const char* gradeColor = data["gradeColor"]; + const char* boardPath = data["boardPath"]; int angle = data["angle"] | 0; + JsonArray commands = data["commands"]; + int count = commands.isNull() ? 0 : commands.size(); + + Logger.logln("LED Update: %s [%s] @ %d degrees (%d holds), queueItemUuid: %s", climbName ? climbName : "(none)", + climbGrade ? climbGrade : "?", angle, count, queueItemUuid ? queueItemUuid : "(none)"); + + // Check if this confirms a pending navigation + if (Display.hasPendingNavigation() && queueItemUuid) { + const char* pendingUuid = Display.getPendingQueueItemUuid(); + if (pendingUuid && strcmp(queueItemUuid, pendingUuid) == 0) { + Logger.logln("LED Update confirms pending navigation to %s", queueItemUuid); + Display.clearPendingNavigation(); + } else { + Logger.logln("LED Update conflicts with pending navigation (expected %s, got %s)", pendingUuid, + queueItemUuid); + Display.clearPendingNavigation(); + } + } + + // Handle clear/unknown climb command + if (commands.isNull() || count == 0) { + // Check if this is an "Unknown Climb" scenario (BLE loaded a climb not in database) + if (climbName && strcmp(climbName, "Unknown Climb") == 0) { + Logger.logln("LED Update: Unknown climb from BLE - displaying with navigation context"); + + // Update state for unknown climb + hasCurrentClimb = true; + currentQueueItemUuid = ""; + currentClimbUuid = ""; + currentClimbName = climbName; + currentGrade = climbGrade ? climbGrade : "?"; + currentGradeColor = gradeColor ? gradeColor : "#888888"; + + // Parse navigation context if present (allows navigating back to known climbs) + if (data["navigation"].is()) { + JsonObject nav = data["navigation"]; + int currentIndex = nav["currentIndex"] | -1; + int totalCount = nav["totalCount"] | 0; + + // Parse previous climb (first in previousClimbs array = immediate previous) + QueueNavigationItem prevClimb; + if (nav["previousClimbs"].is()) { + JsonArray prevArray = nav["previousClimbs"]; + if (prevArray.size() > 0) { + JsonObject prev = prevArray[0]; + prevClimb = QueueNavigationItem(prev["name"] | "", prev["grade"] | "", prev["gradeColor"] | ""); + } + } + + // Parse next climb + QueueNavigationItem nextClimb; + if (nav["nextClimb"].is()) { + JsonObject next = nav["nextClimb"]; + nextClimb = QueueNavigationItem(next["name"] | "", next["grade"] | "", next["gradeColor"] | ""); + } + + Display.setNavigationContext(prevClimb, nextClimb, currentIndex, totalCount); + } else { + Display.clearNavigationContext(); + } + + // Show unknown climb on display + Display.showClimb(climbName, currentGrade.c_str(), currentGradeColor.c_str(), 0, "", boardType.c_str()); + return; + } + + // Normal clear - no climb selected + if (hasCurrentClimb && currentClimbName.length() > 0) { + Display.addToHistory(currentClimbName.c_str(), currentGrade.c_str(), currentGradeColor.c_str()); + } + + hasCurrentClimb = false; + currentQueueItemUuid = ""; + currentClimbUuid = ""; + currentClimbName = ""; + currentGrade = ""; + currentGradeColor = ""; + + Display.showNoClimb(); + return; + } + + // Add previous climb to history if different + if (hasCurrentClimb && currentClimbUuid.length() > 0 && climbUuid && String(climbUuid) != currentClimbUuid) { + Display.addToHistory(currentClimbName.c_str(), currentGrade.c_str(), currentGradeColor.c_str()); + } + // Extract board type from boardPath (e.g., "kilter/1/12/1,2,3/40" -> "kilter") - String boardType = "kilter"; if (boardPath) { String bp = boardPath; int slashPos = bp.indexOf('/'); @@ -365,15 +593,151 @@ void handleLedUpdateExtended(JsonObject& data) { } } - if (climbName && climbUuid) { - // lilygo-display uses showClimb with gradeColor (hex), but we don't have it - // Pass empty string for gradeColor - display will use default - Display.showClimb(climbName, climbGrade ? climbGrade : "", - "", // gradeColor - not available from backend yet - angle, climbUuid, boardType.c_str()); + // Update state + currentQueueItemUuid = queueItemUuid ? queueItemUuid : ""; + currentClimbUuid = climbUuid ? climbUuid : ""; + currentClimbName = climbName ? climbName : ""; + currentGrade = climbGrade ? climbGrade : ""; + currentGradeColor = gradeColor ? gradeColor : ""; + hasCurrentClimb = true; + + // Sync local queue index with backend if we have queueItemUuid + if (queueItemUuid && Display.getQueueCount() > 0) { + for (int i = 0; i < Display.getQueueCount(); i++) { + const LocalQueueItem* item = Display.getQueueItem(i); + if (item && strcmp(item->uuid, queueItemUuid) == 0) { + Display.setCurrentQueueIndex(i); + Logger.logln("LED Update: Synced local queue index to %d", i); + break; + } + } + } + + // Parse navigation context if present + if (data["navigation"].is()) { + JsonObject nav = data["navigation"]; + int currentIndex = nav["currentIndex"] | -1; + int totalCount = nav["totalCount"] | 0; + + // Parse previous climb (first in previousClimbs array = immediate previous) + QueueNavigationItem prevClimb; + if (nav["previousClimbs"].is()) { + JsonArray prevArray = nav["previousClimbs"]; + if (prevArray.size() > 0) { + JsonObject prev = prevArray[0]; + prevClimb = QueueNavigationItem(prev["name"] | "", prev["grade"] | "", prev["gradeColor"] | ""); + } + } + + // Parse next climb + QueueNavigationItem nextClimb; + if (nav["nextClimb"].is()) { + JsonObject next = nav["nextClimb"]; + nextClimb = QueueNavigationItem(next["name"] | "", next["grade"] | "", next["gradeColor"] | ""); + } + + Display.setNavigationContext(prevClimb, nextClimb, currentIndex, totalCount); + Logger.logln("Navigation: index %d/%d, prev: %s, next: %s", currentIndex + 1, totalCount, + prevClimb.isValid ? "yes" : "no", nextClimb.isValid ? "yes" : "no"); } else { - // No climb - clear display - Display.showNoClimb(); + Display.clearNavigationContext(); + } + + // Update display with gradeColor + Display.showClimb(climbName, climbGrade ? climbGrade : "", currentGradeColor.c_str(), angle, + climbUuid ? climbUuid : "", boardType.c_str()); +} + +/** + * Send navigation mutation to backend + */ +void sendNavigationMutation(const char* queueItemUuid) { + String sessionId = Config.getString("session_id"); + if (sessionId.length() == 0) { + Logger.logln("Navigation: No session ID configured"); + return; + } + + // Use queueItemUuid for direct navigation (most reliable) + String vars = "{\"sessionId\":\"" + sessionId + "\",\"direction\":\"next\""; + vars += ",\"queueItemUuid\":\"" + String(queueItemUuid) + "\""; + vars += "}"; + + GraphQL.sendMutation("nav-direct", + "mutation NavDirect($sessionId: ID!, $direction: String!, $queueItemUuid: String) { " + "navigateQueue(sessionId: $sessionId, direction: $direction, queueItemUuid: $queueItemUuid) { " + "uuid climb { name difficulty } } }", + vars.c_str()); + + Logger.logln("Navigation: Sent navigate request to queueItemUuid: %s", queueItemUuid); +} + +/** + * Navigate to previous climb in queue + */ +void navigatePrevious() { + if (!backendConnected) { + Logger.logln("Navigation: Cannot navigate - not connected to backend"); + return; + } + + if (Display.getQueueCount() == 0) { + Logger.logln("Navigation: No queue state - cannot navigate"); + return; + } + + if (!Display.canNavigatePrevious()) { + Logger.logln("Navigation: Already at start of queue"); + return; + } + + // Optimistic update - immediately show previous climb + if (Display.navigateToPrevious()) { + const LocalQueueItem* newCurrent = Display.getCurrentQueueItem(); + if (newCurrent) { + Logger.logln("Navigation: Optimistic update to previous - %s (uuid: %s)", newCurrent->name, + newCurrent->uuid); + + // Update display immediately (optimistic) + Display.showClimb(newCurrent->name, newCurrent->grade, "", 0, newCurrent->climbUuid, boardType.c_str()); + + // Send mutation with queueItemUuid for backend sync + sendNavigationMutation(newCurrent->uuid); + } + } +} + +/** + * Navigate to next climb in queue + */ +void navigateNext() { + if (!backendConnected) { + Logger.logln("Navigation: Cannot navigate - not connected to backend"); + return; + } + + if (Display.getQueueCount() == 0) { + Logger.logln("Navigation: No queue state - cannot navigate"); + return; + } + + if (!Display.canNavigateNext()) { + Logger.logln("Navigation: Already at end of queue"); + return; + } + + // Optimistic update - immediately show next climb + if (Display.navigateToNext()) { + const LocalQueueItem* newCurrent = Display.getCurrentQueueItem(); + if (newCurrent) { + Logger.logln("Navigation: Optimistic update to next - %s (uuid: %s)", newCurrent->name, newCurrent->uuid); + + // Update display immediately (optimistic) + Display.showClimb(newCurrent->name, newCurrent->grade, "", 0, newCurrent->climbUuid, boardType.c_str()); + + // Send mutation with queueItemUuid for backend sync + sendNavigationMutation(newCurrent->uuid); + } } } #endif diff --git a/packages/backend/src/graphql/context.ts b/packages/backend/src/graphql/context.ts index 5f6d8d44..f116de69 100644 --- a/packages/backend/src/graphql/context.ts +++ b/packages/backend/src/graphql/context.ts @@ -16,7 +16,8 @@ export function createContext( isAuthenticated?: boolean, userId?: string, controllerId?: string, - controllerApiKey?: string + controllerApiKey?: string, + controllerMac?: string ): ConnectionContext { const id = connectionId || uuidv4(); const context: ConnectionContext = { @@ -26,9 +27,10 @@ export function createContext( isAuthenticated: isAuthenticated || false, controllerId, controllerApiKey, + controllerMac, }; connections.set(id, context); - console.log(`[Context] createContext: ${id} (authenticated: ${isAuthenticated}, userId: ${userId}, controllerId: ${controllerId}). Total connections: ${connections.size}`); + console.log(`[Context] createContext: ${id} (authenticated: ${isAuthenticated}, userId: ${userId}, controllerId: ${controllerId}, mac: ${controllerMac}). Total connections: ${connections.size}`); return context; } diff --git a/packages/backend/src/graphql/resolvers/controller/mutations.ts b/packages/backend/src/graphql/resolvers/controller/mutations.ts index d5c6d80a..ed5bf1ea 100644 --- a/packages/backend/src/graphql/resolvers/controller/mutations.ts +++ b/packages/backend/src/graphql/resolvers/controller/mutations.ts @@ -194,6 +194,19 @@ export const controllerMutations = { if (!match) { console.log(`[Controller] No climb found matching frames for session ${sessionId}`); + + // Publish event so ESP32 display shows "Unknown Climb" with ability to navigate back + // Use controllerMac as clientId so ESP32 can compare with its own MAC address + const clientIdForEvent = ctx.controllerMac || controllerId; + console.log(`[Controller] Publishing CurrentClimbChanged (no match) with clientId: ${clientIdForEvent}`); + pubsub.publishQueueEvent(sessionId, { + __typename: 'CurrentClimbChanged', + sequence: currentState.sequence, + item: null, // No queue item for unknown climb + clientId: clientIdForEvent, // ESP32 compares this with its MAC + correlationId: null, + }); + return { matched: false, climbUuid: null, @@ -258,14 +271,15 @@ export const controllerMutations = { position: insertPosition, }); - // Publish CurrentClimbChanged event with controllerId as clientId - // This allows the controller subscription to skip sending LED updates back to this controller - console.log(`[Controller] Publishing CurrentClimbChanged with clientId: ${controllerId}`); + // Publish CurrentClimbChanged event with controllerMac as clientId + // ESP32 compares this with its own MAC address to decide whether to disconnect BLE client + const matchClientId = ctx.controllerMac || controllerId; + console.log(`[Controller] Publishing CurrentClimbChanged with clientId: ${matchClientId}`); pubsub.publishQueueEvent(sessionId, { __typename: 'CurrentClimbChanged', sequence, item: queueItem, - clientId: controllerId, + clientId: matchClientId, correlationId: null, }); diff --git a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts index 655e2fdc..c760b3e5 100644 --- a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts +++ b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts @@ -127,7 +127,8 @@ export const controllerSubscriptions = { // Helper to build LedUpdate with navigation context const buildLedUpdateWithNavigation = async ( climb: { uuid: string; name: string; difficulty: string; angle: number; litUpHoldsMap: Record } | null | undefined, - currentItemUuid?: string + currentItemUuid?: string, + clientId?: string | null ): Promise => { // Get LED placements for this controller's configuration const ledPlacements = getLedPlacements( @@ -137,17 +138,21 @@ export const controllerSubscriptions = { ); if (!climb) { - // No current climb - clear LEDs + // No current climb - could be clearing or unknown climb from BLE + // Get queue state for navigation context so ESP32 can navigate back + const queueState = await roomManager.getQueueState(sessionId); + const navigation = buildNavigationContext(queueState.queue, -1); + return { __typename: 'LedUpdate', commands: [], boardPath, - navigation: { - previousClimbs: [], - nextClimb: null, - currentIndex: -1, - totalCount: 0, - }, + clientId, + // If clientId matches controller, this is an unknown BLE climb - show "Unknown Climb" + climbName: clientId ? 'Unknown Climb' : undefined, + climbGrade: clientId ? '?' : undefined, + gradeColor: clientId ? '#888888' : undefined, + navigation, }; } @@ -172,6 +177,7 @@ export const controllerSubscriptions = { boardPath, angle: climb.angle, navigation, + clientId, }; }; @@ -202,15 +208,15 @@ export const controllerSubscriptions = { } // Handle current climb changes and full sync + // Always send LedUpdate with clientId - ESP32 uses clientId to decide whether to disconnect BLE client if (queueEvent.__typename === 'CurrentClimbChanged' || queueEvent.__typename === 'FullSync') { - // Skip LedUpdate if this controller initiated the change - // (LEDs are already set from BLE data, this would be redundant) + // Extract clientId from the event (null for FullSync or system-initiated changes) + const eventClientId = queueEvent.__typename === 'CurrentClimbChanged' + ? queueEvent.clientId + : null; + if (queueEvent.__typename === 'CurrentClimbChanged') { - console.log(`[Controller] CurrentClimbChanged event - clientId: ${queueEvent.clientId}, controllerId: ${controllerId}`); - if (queueEvent.clientId === controllerId) { - console.log(`[Controller] Skipping LedUpdate (originated from this controller)`); - return; - } + console.log(`[Controller] CurrentClimbChanged event - clientId: ${eventClientId}, controllerId: ${controllerId}`); } const currentItem = queueEvent.__typename === 'CurrentClimbChanged' @@ -223,13 +229,14 @@ export const controllerSubscriptions = { try { if (climb) { console.log(`[Controller] Sending LED update for climb: ${climb.name} (uuid: ${currentItem?.uuid})`); - const ledUpdate = await buildLedUpdateWithNavigation(climb, currentItem?.uuid); - console.log(`[Controller] LED update navigation: index ${ledUpdate.navigation?.currentIndex}/${ledUpdate.navigation?.totalCount}, climb: ${ledUpdate.climbName}`); + const ledUpdate = await buildLedUpdateWithNavigation(climb, currentItem?.uuid, eventClientId); + console.log(`[Controller] LED update navigation: index ${ledUpdate.navigation?.currentIndex}/${ledUpdate.navigation?.totalCount}, climb: ${ledUpdate.climbName}, clientId: ${ledUpdate.clientId}`); console.log(`[Controller] Pushing LED update to subscription`); push(ledUpdate); } else { - console.log(`[Controller] Clearing LEDs (no current climb)`); - const ledUpdate = await buildLedUpdateWithNavigation(null); + // No climb - could be clearing or unknown climb + console.log(`[Controller] Sending update with no climb (clientId: ${eventClientId})`); + const ledUpdate = await buildLedUpdateWithNavigation(null, undefined, eventClientId); push(ledUpdate); } } catch (error) { diff --git a/packages/backend/src/websocket/setup.ts b/packages/backend/src/websocket/setup.ts index 2121ee85..b632c084 100644 --- a/packages/backend/src/websocket/setup.ts +++ b/packages/backend/src/websocket/setup.ts @@ -80,9 +80,9 @@ export function setupWebSocketServer(httpServer: HttpServer): WebSocketServer { // Check for controller API key authentication let controllerId: string | undefined; let controllerApiKey: string | undefined; - const extractedControllerApiKey = extractControllerApiKey( - ctx.connectionParams as Record | undefined - ); + let controllerMac: string | undefined; + const connectionParams = ctx.connectionParams as Record | undefined; + const extractedControllerApiKey = extractControllerApiKey(connectionParams); if (extractedControllerApiKey) { const controllerResult = await validateControllerApiKey(extractedControllerApiKey); @@ -93,8 +93,14 @@ export function setupWebSocketServer(httpServer: HttpServer): WebSocketServer { } } + // Extract controller MAC address from connection params (used as clientId for BLE disconnect logic) + if (connectionParams?.controllerMac && typeof connectionParams.controllerMac === 'string') { + controllerMac = connectionParams.controllerMac; + console.log(`[Auth] Controller MAC: ${controllerMac}`); + } + // Create context on initial connection with auth info - const context = createContext(undefined, isAuthenticated, authenticatedUserId, controllerId, controllerApiKey); + const context = createContext(undefined, isAuthenticated, authenticatedUserId, controllerId, controllerApiKey, controllerMac); await roomManager.registerClient(context.connectionId); console.log(`Client connected: ${context.connectionId} (authenticated: ${isAuthenticated})`); diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 5f664034..b8358b88 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -1465,6 +1465,8 @@ export const typeDefs = /* GraphQL */ ` boardPath: String angle: Int navigation: QueueNavigationContext + "ID of client that triggered this update (null if system-initiated). ESP32 uses this to decide whether to disconnect BLE client." + clientId: String } # Ping event to keep controller connection alive diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 61036d32..39190e8b 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -330,6 +330,7 @@ export type ConnectionContext = { // Controller-specific context (set when using API key auth) controllerId?: string; controllerApiKey?: string; + controllerMac?: string; // Controller's MAC address (used as clientId for BLE disconnect logic) }; // ============================================ @@ -372,6 +373,9 @@ export type LedUpdate = { boardPath?: string; angle?: number; navigation?: QueueNavigationContext | null; + // ID of client that triggered this update (null if system-initiated) + // ESP32 uses this to decide whether to disconnect BLE client + clientId?: string | null; }; // Ping event to keep controller connection alive From dc551c7f7c29aa8a9a6e8f64a76ac06b39d2b133 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Feb 2026 15:04:30 +1100 Subject: [PATCH 03/14] fix: Prevent BLE GATT server duplicate start crash (rc=15) - Use pServer->start() instead of ble_gatts_start() directly to set NimBLE's internal m_gattsStarted flag - Move UUID/advertising config to begin() to avoid duplicate registration - Use BLE.startAdvertising() in proxy mode for consistent state management Co-Authored-By: Claude Opus 4.5 --- embedded/libs/ble-proxy/src/ble_proxy.cpp | 68 +++++++++++++++++-- .../nordic-uart-ble/src/nordic_uart_ble.cpp | 48 ++++++++----- 2 files changed, 91 insertions(+), 25 deletions(-) diff --git a/embedded/libs/ble-proxy/src/ble_proxy.cpp b/embedded/libs/ble-proxy/src/ble_proxy.cpp index 249c2623..6b00d057 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.cpp +++ b/embedded/libs/ble-proxy/src/ble_proxy.cpp @@ -11,6 +11,7 @@ #include #include +#include BLEProxy Proxy; @@ -36,6 +37,12 @@ static void onScanCompleteStatic(const std::vector& boards) { } } +static void onBoardFoundStatic(const DiscoveredBoard& board) { + if (proxyInstance) { + proxyInstance->handleBoardFound(board); + } +} + BLEProxy::BLEProxy() : state(BLEProxyState::PROXY_DISABLED), enabled(false), scanStartTime(0), reconnectDelay(5000), stateCallback(nullptr), dataCallback(nullptr), sendToAppCallback(nullptr) { @@ -97,7 +104,22 @@ void BLEProxy::loop() { break; case BLEProxyState::SCANNING: - // Handled by scanner callbacks + // Check if we found a board to connect to + if (pendingConnectName.length() > 0) { + Logger.logln("BLEProxy: Connecting to %s", pendingConnectName.c_str()); + + // Stop the scan from main loop (safe) + Scanner.stopScan(); + + Logger.logln("BLEProxy: Addr: %s", pendingConnectAddress.toString().c_str()); + setState(BLEProxyState::CONNECTING); + + // Brief delay for scan cleanup + delay(100); + + // Now connect + BoardClient.connect(pendingConnectAddress); + } break; case BLEProxyState::CONNECTING: @@ -178,13 +200,26 @@ void BLEProxy::startScan() { setState(BLEProxyState::SCANNING); scanStartTime = millis(); - Scanner.startScan(nullptr, onScanCompleteStatic, 30); + // Use result callback to connect immediately when board found + Scanner.startScan(onBoardFoundStatic, onScanCompleteStatic, 30); +} + +void BLEProxy::handleBoardFound(const DiscoveredBoard& board) { + // If we're still scanning and haven't found a board yet, mark it for connection + // Don't stop scan or connect from callback - do it in loop() to avoid re-entry + if (state == BLEProxyState::SCANNING && pendingConnectName.length() == 0) { + Logger.logln("BLEProxy: Found %s", board.name.c_str()); + + // Store target info - loop() will handle connection + pendingConnectAddress = board.address; + pendingConnectName = board.name; + } } void BLEProxy::handleScanComplete(const std::vector& boards) { if (boards.empty()) { - Logger.logln("BLEProxy: No boards found, will retry"); - setState(BLEProxyState::IDLE); + Logger.logln("BLEProxy: No boards found (reboot to scan again)"); + setState(BLEProxyState::SCAN_COMPLETE_NONE); return; } @@ -204,9 +239,19 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { } if (target) { - Logger.logln("BLEProxy: Connecting to %s (%s)", target->name.c_str(), target->address.toString().c_str()); + // Store target info for connection + pendingConnectAddress = target->address; + pendingConnectName = target->name; + + Logger.logln("BLEProxy: Will connect to %s (%s)", target->name.c_str(), target->address.toString().c_str()); + + // Small delay to allow NimBLE to fully release scan resources + // before initiating client connection setState(BLEProxyState::CONNECTING); - BoardClient.connect(target->address); + delay(100); + + Logger.logln("BLEProxy: Initiating connection..."); + BoardClient.connect(pendingConnectAddress); } else { setState(BLEProxyState::IDLE); } @@ -214,8 +259,17 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { void BLEProxy::handleBoardConnected(bool connected) { if (connected) { - Logger.logln("BLEProxy: Connected to board"); + Logger.logln("BLEProxy: Connected to board!"); setState(BLEProxyState::CONNECTED); + + // Now that we're connected to the real board, start advertising + // so phone apps can connect to us + delay(200); // Brief delay after client connection stabilizes + Logger.logln("BLEProxy: Starting BLE advertising"); + + // Use BLE.startAdvertising() which properly manages advertising state + // and avoids duplicate GATT server start issues + BLE.startAdvertising(); } else { Logger.logln("BLEProxy: Board disconnected"); setState(BLEProxyState::RECONNECTING); diff --git a/embedded/libs/nordic-uart-ble/src/nordic_uart_ble.cpp b/embedded/libs/nordic-uart-ble/src/nordic_uart_ble.cpp index 17703bb4..81744607 100644 --- a/embedded/libs/nordic-uart-ble/src/nordic_uart_ble.cpp +++ b/embedded/libs/nordic-uart-ble/src/nordic_uart_ble.cpp @@ -6,13 +6,16 @@ NordicUartBLE BLE; NordicUartBLE::NordicUartBLE() : pServer(nullptr), pTxCharacteristic(nullptr), pRxCharacteristic(nullptr), deviceConnected(false), - advertising(false), connectedDeviceHandle(BLE_HS_CONN_HANDLE_NONE), connectCallback(nullptr), - dataCallback(nullptr), ledDataCallback(nullptr), rawForwardCallback(nullptr) {} + advertising(false), advertisingEnabled(false), connectedDeviceHandle(BLE_HS_CONN_HANDLE_NONE), + connectCallback(nullptr), dataCallback(nullptr), ledDataCallback(nullptr), rawForwardCallback(nullptr) {} -void NordicUartBLE::begin(const char* deviceName) { +void NordicUartBLE::begin(const char* deviceName, bool startAdv) { NimBLEDevice::init(deviceName); NimBLEDevice::setPower(ESP_PWR_LVL_P9); + // Set whether advertising is allowed (proxy mode delays this) + advertisingEnabled = startAdv; + pServer = NimBLEDevice::createServer(); pServer->setCallbacks(this); @@ -29,14 +32,33 @@ void NordicUartBLE::begin(const char* deviceName) { pService->start(); - startAdvertising(); + // Always configure advertising data (even if not starting yet) + NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising(); + pAdvertising->addServiceUUID(AURORA_ADVERTISED_SERVICE_UUID); + pAdvertising->addServiceUUID(NUS_SERVICE_UUID); + pAdvertising->setScanResponse(true); + pAdvertising->setMinPreferred(0x06); + pAdvertising->setMaxPreferred(0x12); + + // Always start the GATT server (required before advertising can work) + // This registers the services - must be done even if not advertising yet + // Use pServer->start() instead of ble_gatts_start() directly to set NimBLE's + // internal m_gattsStarted flag, preventing duplicate start errors (rc=15) + pServer->start(); + + if (startAdv) { + pAdvertising->start(); + advertising = true; + advertisingEnabled = true; + Logger.logln("BLE: Advertising started"); + } Logger.logln("BLE: Server started as '%s'", deviceName); } void NordicUartBLE::loop() { - // Restart advertising if disconnected and not currently advertising - if (!deviceConnected && !advertising) { + // Restart advertising if disconnected, not currently advertising, and allowed + if (advertisingEnabled && !deviceConnected && !advertising) { delay(500); // Small delay before re-advertising startAdvertising(); } @@ -196,20 +218,10 @@ void NordicUartBLE::onWrite(NimBLECharacteristic* characteristic) { void NordicUartBLE::startAdvertising() { NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising(); - // Add Aurora's custom advertised UUID for discovery by Kilter/Tension apps - pAdvertising->addServiceUUID(AURORA_ADVERTISED_SERVICE_UUID); - - // Add Nordic UART service UUID - pAdvertising->addServiceUUID(NUS_SERVICE_UUID); - - // Set scan response data - pAdvertising->setScanResponse(true); - pAdvertising->setMinPreferred(0x06); // Connection interval min - pAdvertising->setMaxPreferred(0x12); // Connection interval max - - // Start advertising + // Start advertising (UUIDs and settings already configured in begin()) pAdvertising->start(); advertising = true; + advertisingEnabled = true; // Enable for future restarts in loop() Logger.logln("BLE: Advertising started"); } From b9c60f4dafa5feeff67c92f273af8bf33d98af5b Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Feb 2026 15:11:04 +1100 Subject: [PATCH 04/14] feat: Forward websocket LED updates to board in proxy mode - Add AuroraProtocol::encodeLedCommands() to encode LED data into Aurora protocol packets for transmission to the board - Add GraphQL LED update callback to notify when websocket updates arrive - In proxy mode, encode and forward LED updates to the real board via BLE This enables the web interface to control the physical board when running through the ESP32 proxy. Co-Authored-By: Claude Opus 4.5 --- .../aurora-protocol/src/aurora_protocol.cpp | 76 +++++++++++++++++++ .../aurora-protocol/src/aurora_protocol.h | 11 +++ .../src/graphql_ws_client.cpp | 11 ++- .../graphql-ws-client/src/graphql_ws_client.h | 3 + .../projects/board-controller/src/main.cpp | 41 +++++++++- 5 files changed, 140 insertions(+), 2 deletions(-) diff --git a/embedded/libs/aurora-protocol/src/aurora_protocol.cpp b/embedded/libs/aurora-protocol/src/aurora_protocol.cpp index 05c8dee8..193917e8 100644 --- a/embedded/libs/aurora-protocol/src/aurora_protocol.cpp +++ b/embedded/libs/aurora-protocol/src/aurora_protocol.cpp @@ -239,6 +239,82 @@ void AuroraProtocol::decodeLedDataV3(const uint8_t* data, size_t length, std::ve } } +void AuroraProtocol::encodeLedCommands(const LedCommand* commands, int count, + std::vector>& packets) { + packets.clear(); + + if (count == 0) { + return; + } + + // Use V3 format (3 bytes per LED) - most compatible with current boards + // Max BLE packet size is ~240 bytes, with framing overhead we can fit ~75 LEDs per packet + // To be safe, use 60 LEDs per packet + const int LEDS_PER_PACKET = 60; + const int BYTES_PER_LED = 3; + + int totalPackets = (count + LEDS_PER_PACKET - 1) / LEDS_PER_PACKET; + int ledIndex = 0; + + for (int packetNum = 0; packetNum < totalPackets; packetNum++) { + int ledsInThisPacket = min(LEDS_PER_PACKET, count - ledIndex); + + // Determine command byte based on packet position + uint8_t command; + if (totalPackets == 1) { + command = CMD_V3_PACKET_ONLY; // 'T' - single packet + } else if (packetNum == 0) { + command = CMD_V3_PACKET_FIRST; // 'R' - first packet + } else if (packetNum == totalPackets - 1) { + command = CMD_V3_PACKET_LAST; // 'S' - last packet + } else { + command = CMD_V3_PACKET_MIDDLE; // 'Q' - middle packet + } + + // Build the data portion (command + LED data) + std::vector data; + data.push_back(command); + + for (int i = 0; i < ledsInThisPacket; i++) { + const LedCommand& cmd = commands[ledIndex + i]; + + // Position (little-endian) + data.push_back(cmd.position & 0xFF); + data.push_back((cmd.position >> 8) & 0xFF); + + // Color: RRRGGGBB format (3 bits red, 3 bits green, 2 bits blue) + // Convert 8-bit colors to packed format + uint8_t r3 = (cmd.r * 7) / 255; // Scale 0-255 to 0-7 + uint8_t g3 = (cmd.g * 7) / 255; // Scale 0-255 to 0-7 + uint8_t b2 = (cmd.b * 3) / 255; // Scale 0-255 to 0-3 + uint8_t color = (r3 << 5) | (g3 << 2) | b2; + data.push_back(color); + } + + // Build the complete framed packet + // Format: [SOH, length, checksum, STX, ...data..., ETX] + uint8_t dataLength = data.size(); + + // Calculate checksum (XOR of all data bytes with 0xFF) + uint8_t checksum = 0; + for (uint8_t byte : data) { + checksum = (checksum + byte) & 0xFF; + } + checksum ^= 0xFF; + + std::vector packet; + packet.push_back(FRAME_SOH); // 0x01 + packet.push_back(dataLength); // Length of data + packet.push_back(checksum); // Checksum + packet.push_back(FRAME_STX); // 0x02 + packet.insert(packet.end(), data.begin(), data.end()); + packet.push_back(FRAME_ETX); // 0x03 + + packets.push_back(packet); + ledIndex += ledsInThisPacket; + } +} + bool AuroraProtocol::processMessage(uint8_t command, const uint8_t* data, size_t length) { std::vector commands; diff --git a/embedded/libs/aurora-protocol/src/aurora_protocol.h b/embedded/libs/aurora-protocol/src/aurora_protocol.h index 606fd455..f2e6a957 100644 --- a/embedded/libs/aurora-protocol/src/aurora_protocol.h +++ b/embedded/libs/aurora-protocol/src/aurora_protocol.h @@ -108,6 +108,17 @@ class AuroraProtocol { // Enable/disable debug output void setDebug(bool enabled); + /** + * Encode LED commands into Aurora protocol format for sending to a board. + * Creates one or more BLE packets in the proper framed format. + * + * @param commands Array of LED commands to encode + * @param count Number of commands + * @param packets Output vector that will be filled with encoded packets + * Each packet is a complete framed message ready for BLE transmission + */ + static void encodeLedCommands(const LedCommand* commands, int count, std::vector>& packets); + private: // Raw data buffer for incoming BLE packets std::vector rawBuffer; diff --git a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp index e2fab87e..370fdfa5 100644 --- a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp +++ b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp @@ -12,7 +12,7 @@ const char* GraphQLWSClient::KEY_PATH = "gql_path"; GraphQLWSClient::GraphQLWSClient() : state(GraphQLConnectionState::DISCONNECTED), messageCallback(nullptr), stateCallback(nullptr), queueSyncCallback(nullptr), - serverPort(443), useSSL(true), lastPingTime(0), lastPongTime(0), reconnectTime(0), lastSentLedHash(0), currentDisplayHash(0) {} + ledUpdateCallback(nullptr), serverPort(443), useSSL(true), lastPingTime(0), lastPongTime(0), reconnectTime(0), lastSentLedHash(0), currentDisplayHash(0) {} void GraphQLWSClient::begin(const char* host, uint16_t port, const char* path, const char* apiKeyParam) { // Parse protocol prefix from host (ws:// or wss://) @@ -194,6 +194,10 @@ void GraphQLWSClient::setQueueSyncCallback(GraphQLQueueSyncCallback callback) { queueSyncCallback = callback; } +void GraphQLWSClient::setLedUpdateCallback(GraphQLLedUpdateCallback callback) { + ledUpdateCallback = callback; +} + void GraphQLWSClient::onWebSocketEvent(WStype_t type, uint8_t* payload, size_t length) { switch (type) { case WStype_DISCONNECTED: @@ -385,6 +389,11 @@ void GraphQLWSClient::handleLedUpdate(JsonObject& data) { Logger.logln("GraphQL: Updated %d LEDs (clientId: %s)", count, updateClientId ? updateClientId : "null"); } + // Call LED update callback (for proxy forwarding) + if (ledUpdateCallback) { + ledUpdateCallback(ledCommands, count); + } + delete[] ledCommands; } diff --git a/embedded/libs/graphql-ws-client/src/graphql_ws_client.h b/embedded/libs/graphql-ws-client/src/graphql_ws_client.h index a4bd114b..f8cdbecb 100644 --- a/embedded/libs/graphql-ws-client/src/graphql_ws_client.h +++ b/embedded/libs/graphql-ws-client/src/graphql_ws_client.h @@ -39,6 +39,7 @@ struct ControllerQueueSyncData { typedef void (*GraphQLMessageCallback)(JsonDocument& doc); typedef void (*GraphQLStateCallback)(GraphQLConnectionState state); typedef void (*GraphQLQueueSyncCallback)(const ControllerQueueSyncData& data); +typedef void (*GraphQLLedUpdateCallback)(const LedCommand* commands, int count); class GraphQLWSClient { public: @@ -70,6 +71,7 @@ class GraphQLWSClient { void setMessageCallback(GraphQLMessageCallback callback); void setStateCallback(GraphQLStateCallback callback); void setQueueSyncCallback(GraphQLQueueSyncCallback callback); + void setLedUpdateCallback(GraphQLLedUpdateCallback callback); // Handle LED update from backend void handleLedUpdate(JsonObject& data); @@ -97,6 +99,7 @@ class GraphQLWSClient { GraphQLMessageCallback messageCallback; GraphQLStateCallback stateCallback; GraphQLQueueSyncCallback queueSyncCallback; + GraphQLLedUpdateCallback ledUpdateCallback; String serverHost; uint16_t serverPort; diff --git a/embedded/projects/board-controller/src/main.cpp b/embedded/projects/board-controller/src/main.cpp index bc81d7b9..ca203b98 100644 --- a/embedded/projects/board-controller/src/main.cpp +++ b/embedded/projects/board-controller/src/main.cpp @@ -60,6 +60,7 @@ void startupAnimation(); #ifdef ENABLE_BLE_PROXY void onBLERawForward(const uint8_t* data, size_t len); void onProxyStateChange(BLEProxyState state); +void onWebSocketLedUpdate(const LedCommand* commands, int count); // Function to send data to app via BLE (used by proxy) void sendToAppViaBLE(const uint8_t* data, size_t len) { @@ -128,7 +129,13 @@ void setup() { // Initialize BLE - always use BLE_DEVICE_NAME for Kilter app compatibility Logger.logln("Initializing BLE as '%s'...", BLE_DEVICE_NAME); - BLE.begin(BLE_DEVICE_NAME); +#ifdef ENABLE_BLE_PROXY + // When proxy is enabled, don't advertise yet - connect to board first + // Advertising will start after successful connection to real board + BLE.begin(BLE_DEVICE_NAME, false); +#else + BLE.begin(BLE_DEVICE_NAME, true); +#endif BLE.setConnectCallback(onBLEConnect); BLE.setDataCallback(onBLEData); BLE.setLedDataCallback(onBLELedData); @@ -277,6 +284,10 @@ void onWiFiStateChange(WiFiConnectionState state) { Logger.logln("Connecting to backend: %s:%d%s", host.c_str(), port, path.c_str()); GraphQL.setStateCallback(onGraphQLStateChange); GraphQL.setMessageCallback(onGraphQLMessage); +#ifdef ENABLE_BLE_PROXY + // Set up LED update callback for proxy forwarding + GraphQL.setLedUpdateCallback(onWebSocketLedUpdate); +#endif GraphQL.begin(host.c_str(), port, path.c_str(), apiKey.c_str()); break; } @@ -348,6 +359,34 @@ void onProxyStateChange(BLEProxyState state) { } #endif } + +/** + * Callback for LED updates received via WebSocket + * Encodes LED commands into Aurora protocol and forwards to the real board + */ +void onWebSocketLedUpdate(const LedCommand* commands, int count) { + if (!Proxy.isConnectedToBoard()) { + Logger.logln("Proxy: Cannot forward LED update - not connected to board"); + return; + } + + Logger.logln("Proxy: Forwarding %d LEDs to board via BLE", count); + + // Encode LED commands into Aurora protocol packets + std::vector> packets; + AuroraProtocol::encodeLedCommands(commands, count, packets); + + // Send each packet to the board + for (const auto& packet : packets) { + Proxy.forwardToBoard(packet.data(), packet.size()); + // Small delay between packets to avoid overwhelming the BLE connection + if (packets.size() > 1) { + delay(20); + } + } + + Logger.logln("Proxy: Sent %zu packets to board", packets.size()); +} #endif /** From 6abec569016fd1a81a0b4bb62088aeb593be01ec Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Feb 2026 16:22:15 +1100 Subject: [PATCH 05/14] fix: BLE proxy connection race condition and write chunking - Fix race condition where handleScanComplete and handleBoardFound both tried to connect, causing state to flip back to CONNECTING - Set state to CONNECTING before calling stopScan() to prevent handleScanComplete from initiating duplicate connection - Split BLE writes into 20-byte chunks to match board's MTU and the TypeScript client implementation (MAX_BLUETOOTH_MESSAGE_SIZE) Co-Authored-By: Claude Opus 4.5 --- embedded/libs/ble-proxy/src/ble_client.cpp | 18 ++++++++---- embedded/libs/ble-proxy/src/ble_client.h | 4 +-- embedded/libs/ble-proxy/src/ble_proxy.cpp | 29 +++++++++++++------ embedded/libs/ble-proxy/src/ble_proxy.h | 18 ++++++++---- embedded/libs/ble-proxy/src/ble_scanner.cpp | 12 +++++++- .../nordic-uart-ble/src/nordic_uart_ble.h | 9 ++++-- .../src/config/board_config.h | 6 ++++ .../projects/board-controller/src/main.cpp | 20 +++++++++---- 8 files changed, 83 insertions(+), 33 deletions(-) diff --git a/embedded/libs/ble-proxy/src/ble_client.cpp b/embedded/libs/ble-proxy/src/ble_client.cpp index 21e20017..487736e5 100644 --- a/embedded/libs/ble-proxy/src/ble_client.cpp +++ b/embedded/libs/ble-proxy/src/ble_client.cpp @@ -23,31 +23,37 @@ BLEClientConnection::BLEClientConnection() bool BLEClientConnection::connect(NimBLEAddress address) { if (state == BLEClientState::CONNECTED || state == BLEClientState::CONNECTING) { - Logger.logln("BLEClient: Already connected or connecting"); + Logger.logln("BLEClient: Already connecting"); return false; } targetAddress = address; state = BLEClientState::CONNECTING; - Logger.logln("BLEClient: Connecting to %s", address.toString().c_str()); + // Log address details + uint8_t addrType = address.getType(); + Logger.logln("BLEClient: Target addr: %s", address.toString().c_str()); + Logger.logln("BLEClient: Addr type: %d (0=pub, 1=rand)", addrType); // Create client if needed if (!pClient) { pClient = NimBLEDevice::createClient(); pClient->setClientCallbacks(this); pClient->setConnectionParams(12, 12, 0, 51); - pClient->setConnectTimeout(CLIENT_CONNECT_TIMEOUT_MS / 1000); + pClient->setConnectTimeout(5); // 5 seconds - connections should be fast } - // Attempt connection - if (!pClient->connect(address)) { - Logger.logln("BLEClient: Connection failed"); + Logger.logln("BLEClient: Calling connect()..."); + + // Attempt connection with explicit address type + if (!pClient->connect(address, true)) { + Logger.logln("BLEClient: connect() returned false"); state = BLEClientState::DISCONNECTED; reconnectTime = millis() + CLIENT_RECONNECT_DELAY_MS; return false; } + Logger.logln("BLEClient: connect() returned true"); return true; } diff --git a/embedded/libs/ble-proxy/src/ble_client.h b/embedded/libs/ble-proxy/src/ble_client.h index 627f4280..3cac076c 100644 --- a/embedded/libs/ble-proxy/src/ble_client.h +++ b/embedded/libs/ble-proxy/src/ble_client.h @@ -10,8 +10,8 @@ #define NUS_TX_CHARACTERISTIC "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" // Connection timing -#define CLIENT_CONNECT_TIMEOUT_MS 10000 -#define CLIENT_RECONNECT_DELAY_MS 5000 +#define CLIENT_CONNECT_TIMEOUT_MS 5000 // 5 second timeout (connections should be fast) +#define CLIENT_RECONNECT_DELAY_MS 3000 enum class BLEClientState { IDLE, CONNECTING, CONNECTED, RECONNECTING, DISCONNECTED }; diff --git a/embedded/libs/ble-proxy/src/ble_proxy.cpp b/embedded/libs/ble-proxy/src/ble_proxy.cpp index 6b00d057..470a1b27 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.cpp +++ b/embedded/libs/ble-proxy/src/ble_proxy.cpp @@ -108,17 +108,24 @@ void BLEProxy::loop() { if (pendingConnectName.length() > 0) { Logger.logln("BLEProxy: Connecting to %s", pendingConnectName.c_str()); - // Stop the scan from main loop (safe) + // Set state FIRST to prevent handleScanComplete from also trying to connect + setState(BLEProxyState::CONNECTING); + + // Clear pending name to signal we've started connection + String connectName = pendingConnectName; + NimBLEAddress connectAddr = pendingConnectAddress; + pendingConnectName = ""; + + // Stop the scan (this may trigger handleScanComplete, but state is already CONNECTING) Scanner.stopScan(); - Logger.logln("BLEProxy: Addr: %s", pendingConnectAddress.toString().c_str()); - setState(BLEProxyState::CONNECTING); + Logger.logln("BLEProxy: Addr: %s", connectAddr.toString().c_str()); // Brief delay for scan cleanup delay(100); // Now connect - BoardClient.connect(pendingConnectAddress); + BoardClient.connect(connectAddr); } break; @@ -217,6 +224,14 @@ void BLEProxy::handleBoardFound(const DiscoveredBoard& board) { } void BLEProxy::handleScanComplete(const std::vector& boards) { + // If we're already connecting or connected (e.g., from handleBoardFound path), + // don't try to connect again + if (state == BLEProxyState::CONNECTING || state == BLEProxyState::CONNECTED) { + Logger.logln("BLEProxy: Scan complete, already %s", + state == BLEProxyState::CONNECTED ? "connected" : "connecting"); + return; + } + if (boards.empty()) { Logger.logln("BLEProxy: No boards found (reboot to scan again)"); setState(BLEProxyState::SCAN_COMPLETE_NONE); @@ -239,10 +254,6 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { } if (target) { - // Store target info for connection - pendingConnectAddress = target->address; - pendingConnectName = target->name; - Logger.logln("BLEProxy: Will connect to %s (%s)", target->name.c_str(), target->address.toString().c_str()); // Small delay to allow NimBLE to fully release scan resources @@ -251,7 +262,7 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { delay(100); Logger.logln("BLEProxy: Initiating connection..."); - BoardClient.connect(pendingConnectAddress); + BoardClient.connect(target->address); } else { setState(BLEProxyState::IDLE); } diff --git a/embedded/libs/ble-proxy/src/ble_proxy.h b/embedded/libs/ble-proxy/src/ble_proxy.h index 11e72064..44f86dbd 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.h +++ b/embedded/libs/ble-proxy/src/ble_proxy.h @@ -8,12 +8,13 @@ // Proxy state machine enum class BLEProxyState { - PROXY_DISABLED, // Proxy mode not enabled - IDLE, // Waiting to scan - SCANNING, // Scanning for boards - CONNECTING, // Connecting to board - CONNECTED, // Connected and proxying - RECONNECTING // Connection lost, will retry + PROXY_DISABLED, // Proxy mode not enabled + IDLE, // Waiting to scan + SCANNING, // Scanning for boards + SCAN_COMPLETE_NONE, // Scan completed but no boards found (won't auto-retry) + CONNECTING, // Connecting to board + CONNECTED, // Connected and proxying + RECONNECTING // Connection lost, will retry }; typedef void (*ProxyStateCallback)(BLEProxyState state); @@ -113,6 +114,7 @@ class BLEProxy { void forwardToApp(const uint8_t* data, size_t len); // Public handlers for static callbacks + void handleBoardFound(const DiscoveredBoard& board); void handleScanComplete(const std::vector& boards); void handleBoardConnected(bool connected); void handleBoardData(const uint8_t* data, size_t len); @@ -124,6 +126,10 @@ class BLEProxy { unsigned long scanStartTime; unsigned long reconnectDelay; + // Pending connection info (stored after scan, before connect) + NimBLEAddress pendingConnectAddress; + String pendingConnectName; + ProxyStateCallback stateCallback; ProxyDataCallback dataCallback; ProxySendToAppCallback sendToAppCallback; diff --git a/embedded/libs/ble-proxy/src/ble_scanner.cpp b/embedded/libs/ble-proxy/src/ble_scanner.cpp index 5f296e79..f5ce5151 100644 --- a/embedded/libs/ble-proxy/src/ble_scanner.cpp +++ b/embedded/libs/ble-proxy/src/ble_scanner.cpp @@ -119,8 +119,18 @@ void BLEScanner::onResult(NimBLEAdvertisedDevice* advertisedDevice) { void BLEScanner::scanCompleteCB(NimBLEScanResults results) { if (instance) { + // Explicitly stop and clear the scan BEFORE doing anything else + // NimBLE requires scan to be fully stopped before creating client connections + if (instance->pScan) { + instance->pScan->stop(); + instance->pScan->clearResults(); + } instance->scanning = false; - Logger.logln("BLEScanner: Scan complete, found %d Aurora boards", instance->discoveredBoards.size()); + + // Wait for BLE stack to settle after scan stops + delay(500); + + Logger.logln("BLEScanner: Scan done, %d boards", instance->discoveredBoards.size()); if (instance->completeCallback) { instance->completeCallback(instance->discoveredBoards); diff --git a/embedded/libs/nordic-uart-ble/src/nordic_uart_ble.h b/embedded/libs/nordic-uart-ble/src/nordic_uart_ble.h index 07f26d11..6a9b7c8c 100644 --- a/embedded/libs/nordic-uart-ble/src/nordic_uart_ble.h +++ b/embedded/libs/nordic-uart-ble/src/nordic_uart_ble.h @@ -25,9 +25,13 @@ class NordicUartBLE : public NimBLEServerCallbacks, public NimBLECharacteristicC public: NordicUartBLE(); - void begin(const char* deviceName); + // Initialize BLE server. If startAdvertising is false, call startAdvertising() later. + void begin(const char* deviceName, bool startAdv = true); void loop(); + // Start BLE advertising (public so proxy can call after board connection) + void startAdvertising(); + bool isConnected(); // Send data to connected client @@ -70,6 +74,7 @@ class NordicUartBLE : public NimBLEServerCallbacks, public NimBLECharacteristicC bool deviceConnected; bool advertising; + bool advertisingEnabled; // Whether advertising is allowed (false until proxy connects) String connectedDeviceAddress; // MAC address of currently connected device uint16_t connectedDeviceHandle; // Connection handle for disconnect std::map lastSentHashByMac; // Track last sent hash per MAC address @@ -80,8 +85,6 @@ class NordicUartBLE : public NimBLEServerCallbacks, public NimBLECharacteristicC BLEDataCallback dataCallback; BLELedDataCallback ledDataCallback; BLERawForwardCallback rawForwardCallback; - - void startAdvertising(); }; extern NordicUartBLE BLE; diff --git a/embedded/projects/board-controller/src/config/board_config.h b/embedded/projects/board-controller/src/config/board_config.h index bbf1c354..84883519 100644 --- a/embedded/projects/board-controller/src/config/board_config.h +++ b/embedded/projects/board-controller/src/config/board_config.h @@ -20,6 +20,12 @@ // Default brightness (0-255) #define DEFAULT_BRIGHTNESS 128 +// Button configuration (T-Display-S3 built-in buttons) +#ifdef ENABLE_DISPLAY +#define BUTTON_1_PIN 0 // GPIO0 - Boot button +#define BUTTON_2_PIN 14 // GPIO14 - User button +#endif + // BLE configuration #define BLE_DEVICE_NAME "Kilter Boardsesh" diff --git a/embedded/projects/board-controller/src/main.cpp b/embedded/projects/board-controller/src/main.cpp index ca203b98..6d1336d2 100644 --- a/embedded/projects/board-controller/src/main.cpp +++ b/embedded/projects/board-controller/src/main.cpp @@ -376,16 +376,24 @@ void onWebSocketLedUpdate(const LedCommand* commands, int count) { std::vector> packets; AuroraProtocol::encodeLedCommands(commands, count, packets); - // Send each packet to the board + // BLE max write size is 20 bytes (matches TypeScript MAX_BLUETOOTH_MESSAGE_SIZE) + const size_t MAX_BLE_CHUNK_SIZE = 20; + int totalChunks = 0; + + // Send each protocol packet, split into 20-byte BLE chunks for (const auto& packet : packets) { - Proxy.forwardToBoard(packet.data(), packet.size()); - // Small delay between packets to avoid overwhelming the BLE connection - if (packets.size() > 1) { - delay(20); + // Split packet into 20-byte chunks + for (size_t offset = 0; offset < packet.size(); offset += MAX_BLE_CHUNK_SIZE) { + size_t chunkSize = min(MAX_BLE_CHUNK_SIZE, packet.size() - offset); + Proxy.forwardToBoard(packet.data() + offset, chunkSize); + totalChunks++; + + // Small delay between chunks to avoid overwhelming the BLE connection + delay(10); } } - Logger.logln("Proxy: Sent %zu packets to board", packets.size()); + Logger.logln("Proxy: Sent %d chunks (%zu protocol packets) to board", totalChunks, packets.size()); } #endif From 23bcfa5d788b2a214ef54a28923e98a6a574fc19 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 4 Feb 2026 18:57:04 +1100 Subject: [PATCH 06/14] Missing files --- embedded/libs/display-ui/library.json | 13 + .../libs/display-ui/src/climb_display.cpp | 448 +++++++++++ embedded/libs/display-ui/src/climb_display.h | 158 ++++ embedded/libs/display-ui/src/display_ui.h | 34 + embedded/libs/display-ui/src/grade_colors.h | 180 +++++ embedded/libs/display-ui/src/qr_generator.cpp | 85 +++ embedded/libs/display-ui/src/qr_generator.h | 82 +++ .../projects/climb-queue-display/README.md | 154 ---- .../climb-queue-display/partitions.csv | 6 - .../climb-queue-display/platformio.ini | 64 -- .../src/config/display_config.h | 41 -- .../projects/climb-queue-display/src/main.cpp | 693 ------------------ package-lock.json | 24 +- .../resolvers/controller/grade-colors.ts | 110 +++ .../controller/navigation-helpers.ts | 62 ++ 15 files changed, 1179 insertions(+), 975 deletions(-) create mode 100644 embedded/libs/display-ui/library.json create mode 100644 embedded/libs/display-ui/src/climb_display.cpp create mode 100644 embedded/libs/display-ui/src/climb_display.h create mode 100644 embedded/libs/display-ui/src/display_ui.h create mode 100644 embedded/libs/display-ui/src/grade_colors.h create mode 100644 embedded/libs/display-ui/src/qr_generator.cpp create mode 100644 embedded/libs/display-ui/src/qr_generator.h delete mode 100644 embedded/projects/climb-queue-display/README.md delete mode 100644 embedded/projects/climb-queue-display/partitions.csv delete mode 100644 embedded/projects/climb-queue-display/platformio.ini delete mode 100644 embedded/projects/climb-queue-display/src/config/display_config.h delete mode 100644 embedded/projects/climb-queue-display/src/main.cpp create mode 100644 packages/backend/src/graphql/resolvers/controller/grade-colors.ts create mode 100644 packages/backend/src/graphql/resolvers/controller/navigation-helpers.ts diff --git a/embedded/libs/display-ui/library.json b/embedded/libs/display-ui/library.json new file mode 100644 index 00000000..7ec9a865 --- /dev/null +++ b/embedded/libs/display-ui/library.json @@ -0,0 +1,13 @@ +{ + "name": "display-ui", + "version": "1.0.0", + "description": "Display UI for T-Display-S3 showing climb info and QR codes", + "keywords": ["display", "tft", "qr", "climbing", "tdisplay"], + "frameworks": ["arduino"], + "platforms": ["espressif32"], + "dependencies": { + "climb-history": "*", + "config-manager": "*", + "log-buffer": "*" + } +} diff --git a/embedded/libs/display-ui/src/climb_display.cpp b/embedded/libs/display-ui/src/climb_display.cpp new file mode 100644 index 00000000..7e9f4ca9 --- /dev/null +++ b/embedded/libs/display-ui/src/climb_display.cpp @@ -0,0 +1,448 @@ +#include "climb_display.h" + +#include +#include +#include + +ClimbDisplay Display; + +#ifdef ENABLE_DISPLAY + +// ============================================ +// LGFX_TDisplayS3 Implementation +// ============================================ + +LGFX_TDisplayS3::LGFX_TDisplayS3() { + // Bus configuration for parallel 8-bit + { + auto cfg = _bus_instance.config(); + cfg.port = 0; + cfg.freq_write = 20000000; + cfg.pin_wr = LCD_WR_PIN; + cfg.pin_rd = LCD_RD_PIN; + cfg.pin_rs = LCD_RS_PIN; + + cfg.pin_d0 = LCD_D0_PIN; + cfg.pin_d1 = LCD_D1_PIN; + cfg.pin_d2 = LCD_D2_PIN; + cfg.pin_d3 = LCD_D3_PIN; + cfg.pin_d4 = LCD_D4_PIN; + cfg.pin_d5 = LCD_D5_PIN; + cfg.pin_d6 = LCD_D6_PIN; + cfg.pin_d7 = LCD_D7_PIN; + + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + // Panel configuration + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = LCD_CS_PIN; + cfg.pin_rst = LCD_RST_PIN; + cfg.pin_busy = -1; + + cfg.memory_width = DISPLAY_WIDTH; + cfg.memory_height = DISPLAY_HEIGHT; + cfg.panel_width = DISPLAY_WIDTH; + cfg.panel_height = DISPLAY_HEIGHT; + cfg.offset_x = 35; + cfg.offset_y = 0; + cfg.offset_rotation = 0; + cfg.dummy_read_pixel = 8; + cfg.dummy_read_bits = 1; + cfg.readable = true; + cfg.invert = true; + cfg.rgb_order = false; + cfg.dlen_16bit = false; + cfg.bus_shared = true; + + _panel_instance.config(cfg); + } + + // Backlight configuration + { + auto cfg = _light_instance.config(); + cfg.pin_bl = LCD_BL_PIN; + cfg.invert = false; + cfg.freq = 12000; + cfg.pwm_channel = 0; + _light_instance.config(cfg); + _panel_instance.setLight(&_light_instance); + } + + setPanel(&_panel_instance); +} + +#endif // ENABLE_DISPLAY + +ClimbDisplay::ClimbDisplay() + : initialized(false), brightness(200), wifiStatus(WiFiStatus::DISCONNECTED), bleStatus(BLEStatus::IDLE), + hasCurrentClimb(false), needsFullRedraw(true), needsStatusRedraw(false), needsCurrentClimbRedraw(false), + needsHistoryRedraw(false) { + currentName[0] = '\0'; + currentGrade[0] = '\0'; +} + +void ClimbDisplay::begin() { +#ifdef ENABLE_DISPLAY + Logger.logln("ClimbDisplay: Initializing"); + + // Load brightness from config + brightness = Config.getInt("disp_br", 200); + + // Enable display power (GPIO 15) + pinMode(LCD_POWER_PIN, OUTPUT); + digitalWrite(LCD_POWER_PIN, HIGH); + delay(50); + + // Initialize display + lcd.init(); + lcd.setRotation(0); // Portrait mode + lcd.setBrightness(brightness); + lcd.fillScreen(COLOR_BLACK); + + initialized = true; + needsFullRedraw = true; + + Logger.logln("ClimbDisplay: Ready"); +#endif +} + +void ClimbDisplay::setCurrentClimb(const char* name, const char* grade, const char* uuid, const char* boardPath, + const char* sessionId) { + // Store climb info + strncpy(currentName, name ? name : "", sizeof(currentName) - 1); + currentName[sizeof(currentName) - 1] = '\0'; + + strncpy(currentGrade, grade ? grade : "", sizeof(currentGrade) - 1); + currentGrade[sizeof(currentGrade) - 1] = '\0'; + + hasCurrentClimb = true; + + // Generate QR code for session join + if (sessionId && strlen(sessionId) > 0) { + QRCode_Gen.generate(sessionId); + } + + // Update history + if (name && uuid) { + ClimbHistoryMgr.addClimb(name, grade, uuid); + } + + needsCurrentClimbRedraw = true; + needsHistoryRedraw = true; +} + +void ClimbDisplay::clearCurrentClimb() { + hasCurrentClimb = false; + currentName[0] = '\0'; + currentGrade[0] = '\0'; + QRCode_Gen.clear(); + ClimbHistoryMgr.clearCurrent(); + needsCurrentClimbRedraw = true; +} + +void ClimbDisplay::setWiFiStatus(WiFiStatus status) { + if (wifiStatus != status) { + wifiStatus = status; + needsStatusRedraw = true; + } +} + +void ClimbDisplay::setBLEStatus(BLEStatus status) { + if (bleStatus != status) { + bleStatus = status; + needsStatusRedraw = true; + } +} + +void ClimbDisplay::setBrightness(uint8_t b) { + brightness = b; + Config.setInt("disp_br", b); + +#ifdef ENABLE_DISPLAY + if (initialized) { + lcd.setBrightness(brightness); + } +#endif +} + +uint8_t ClimbDisplay::getBrightness() const { + return brightness; +} + +void ClimbDisplay::redraw() { + needsFullRedraw = true; +} + +void ClimbDisplay::loop() { +#ifdef ENABLE_DISPLAY + if (!initialized) + return; + + if (needsFullRedraw) { + drawBackground(); + drawStatusBar(); + drawCurrentClimb(); + drawHistoryHeader(); + drawHistoryList(); + needsFullRedraw = false; + needsStatusRedraw = false; + needsCurrentClimbRedraw = false; + needsHistoryRedraw = false; + return; + } + + if (needsCurrentClimbRedraw) { + drawCurrentClimb(); + needsCurrentClimbRedraw = false; + } + + if (needsHistoryRedraw) { + drawHistoryList(); + needsHistoryRedraw = false; + } + + if (needsStatusRedraw) { + drawStatusBar(); + needsStatusRedraw = false; + } +#endif +} + +void ClimbDisplay::drawBackground() { +#ifdef ENABLE_DISPLAY + lcd.fillScreen(COLOR_BLACK); +#endif +} + +void ClimbDisplay::drawStatusBar() { +#ifdef ENABLE_DISPLAY + // Status bar at top + lcd.fillRect(0, 0, DISPLAY_WIDTH, STATUS_BAR_HEIGHT, COLOR_DARK_GRAY); + + lcd.setTextSize(1); + lcd.setTextDatum(lgfx::middle_left); + + // WiFi status + lcd.setCursor(5, STATUS_BAR_HEIGHT / 2 + 1); + lcd.setTextColor(COLOR_WHITE); + lcd.print("WiFi:"); + + switch (wifiStatus) { + case WiFiStatus::CONNECTED: + lcd.setTextColor(COLOR_GREEN); + lcd.print("OK"); + break; + case WiFiStatus::CONNECTING: + lcd.setTextColor(COLOR_CYAN); + lcd.print("..."); + break; + default: + lcd.setTextColor(COLOR_RED); + lcd.print("--"); + break; + } + + // BLE status + lcd.setCursor(70, STATUS_BAR_HEIGHT / 2 + 1); + lcd.setTextColor(COLOR_WHITE); + lcd.print("BLE:"); + + switch (bleStatus) { + case BLEStatus::CONNECTED: + lcd.setTextColor(COLOR_GREEN); + lcd.print("App"); + break; + case BLEStatus::PROXY_CONNECTED: + lcd.setTextColor(COLOR_CYAN); + lcd.print("Prx"); + break; + case BLEStatus::ADVERTISING: + lcd.setTextColor(COLOR_CYAN); + lcd.print("Adv"); + break; + default: + lcd.setTextColor(COLOR_LIGHT_GRAY); + lcd.print("--"); + break; + } +#endif +} + +void ClimbDisplay::drawCurrentClimb() { +#ifdef ENABLE_DISPLAY + int startY = STATUS_BAR_HEIGHT; + + // Clear the current climb area + lcd.fillRect(0, startY, DISPLAY_WIDTH, CURRENT_CLIMB_HEIGHT + HEADER_HEIGHT, COLOR_BLACK); + + // Draw "CURRENT CLIMB" header + lcd.fillRect(0, startY, DISPLAY_WIDTH, HEADER_HEIGHT, COLOR_DARK_GRAY); + lcd.setTextColor(COLOR_CYAN); + lcd.setTextSize(1); + lcd.setCursor(8, startY + 7); + lcd.print("CURRENT CLIMB"); + + int contentY = startY + HEADER_HEIGHT; + + if (!hasCurrentClimb) { + lcd.setTextColor(COLOR_LIGHT_GRAY); + lcd.setTextSize(1); + lcd.setCursor(10, contentY + 35); + lcd.print("No climb selected"); + return; + } + + // Draw QR code on the left + int qrX = 5; + int qrY = contentY + 5; + drawQRCode(qrX, qrY); + + // Draw climb name on the right + int textX = QR_SIZE + 15; + int textY = contentY + 15; + + // Truncate name if too long + char truncatedName[24]; + truncateText(currentName, truncatedName, 10); + + lcd.setTextColor(COLOR_WHITE); + lcd.setTextSize(1); + lcd.setCursor(textX, textY); + lcd.print(truncatedName); + + // Draw grade with color + textY += 25; + char vGrade[8]; + extractVGrade(currentGrade, vGrade, sizeof(vGrade)); + + if (vGrade[0] != '\0') { + uint16_t gradeColor = getVGradeColor(vGrade); + uint16_t textColor = getTextColorForBackground(gradeColor); + + // Draw grade badge + int badgeWidth = 45; + int badgeHeight = 22; + lcd.fillRoundRect(textX, textY, badgeWidth, badgeHeight, 4, gradeColor); + lcd.setTextColor(textColor); + lcd.setTextSize(2); + lcd.setCursor(textX + 6, textY + 4); + lcd.print(vGrade); + } +#endif +} + +void ClimbDisplay::drawQRCode(int x, int y) { +#ifdef ENABLE_DISPLAY + if (!QRCode_Gen.isValid()) { + // Draw placeholder + lcd.drawRect(x, y, QR_SIZE, QR_SIZE, COLOR_DARK_GRAY); + lcd.setTextColor(COLOR_LIGHT_GRAY); + lcd.setTextSize(1); + lcd.setCursor(x + 20, y + 35); + lcd.print("No QR"); + return; + } + + int qrModules = QRCode_Gen.getSize(); + int moduleSize = (QR_SIZE - 8) / qrModules; + int offsetX = x + (QR_SIZE - qrModules * moduleSize) / 2; + int offsetY = y + (QR_SIZE - qrModules * moduleSize) / 2; + + // Draw white background + lcd.fillRect(x, y, QR_SIZE, QR_SIZE, COLOR_WHITE); + + // Draw QR modules + for (int qy = 0; qy < qrModules; qy++) { + for (int qx = 0; qx < qrModules; qx++) { + if (QRCode_Gen.getModule(qx, qy)) { + lcd.fillRect(offsetX + qx * moduleSize, offsetY + qy * moduleSize, moduleSize, moduleSize, COLOR_BLACK); + } + } + } +#endif +} + +void ClimbDisplay::drawHistoryHeader() { +#ifdef ENABLE_DISPLAY + int y = STATUS_BAR_HEIGHT + HEADER_HEIGHT + CURRENT_CLIMB_HEIGHT; + lcd.fillRect(0, y, DISPLAY_WIDTH, HISTORY_HEADER_HEIGHT, COLOR_DARK_GRAY); + lcd.setTextColor(COLOR_CYAN); + lcd.setTextSize(1); + lcd.setCursor(8, y + 5); + lcd.print("RECENT CLIMBS"); +#endif +} + +void ClimbDisplay::drawHistoryList() { +#ifdef ENABLE_DISPLAY + int startY = STATUS_BAR_HEIGHT + HEADER_HEIGHT + CURRENT_CLIMB_HEIGHT + HISTORY_HEADER_HEIGHT; + + // Clear history area + int historyHeight = 5 * HISTORY_ITEM_HEIGHT; + lcd.fillRect(0, startY, DISPLAY_WIDTH, historyHeight, COLOR_BLACK); + + // Draw each history item + for (int i = 0; i < 5; i++) { + drawHistoryItem(i, startY + i * HISTORY_ITEM_HEIGHT); + } +#endif +} + +void ClimbDisplay::drawHistoryItem(int index, int y) { +#ifdef ENABLE_DISPLAY + // Get climb from history (skip index 0 which is current) + const ClimbEntry* entry = ClimbHistoryMgr.getClimb(index + 1); + + if (!entry) { + // Empty slot + lcd.setTextColor(COLOR_DARK_GRAY); + lcd.setTextSize(1); + lcd.setCursor(10, y + 8); + lcd.printf("%d. ---", index + 1); + return; + } + + // Draw index + lcd.setTextColor(COLOR_LIGHT_GRAY); + lcd.setTextSize(1); + lcd.setCursor(8, y + 8); + lcd.printf("%d.", index + 1); + + // Draw climb name (truncated) + char truncatedName[18]; + truncateText(entry->name, truncatedName, 14); + lcd.setCursor(25, y + 8); + lcd.setTextColor(COLOR_WHITE); + lcd.print(truncatedName); + + // Draw grade + char vGrade[8]; + extractVGrade(entry->grade, vGrade, sizeof(vGrade)); + if (vGrade[0] != '\0') { + uint16_t gradeColor = getVGradeColor(vGrade); + lcd.setTextColor(gradeColor); + lcd.setCursor(DISPLAY_WIDTH - 35, y + 8); + lcd.print(vGrade); + } +#endif +} + +void ClimbDisplay::truncateText(const char* input, char* output, int maxChars) { + if (!input) { + output[0] = '\0'; + return; + } + + int len = strlen(input); + if (len <= maxChars) { + strcpy(output, input); + } else { + strncpy(output, input, maxChars - 2); + output[maxChars - 2] = '.'; + output[maxChars - 1] = '.'; + output[maxChars] = '\0'; + } +} diff --git a/embedded/libs/display-ui/src/climb_display.h b/embedded/libs/display-ui/src/climb_display.h new file mode 100644 index 00000000..f80db5bd --- /dev/null +++ b/embedded/libs/display-ui/src/climb_display.h @@ -0,0 +1,158 @@ +#ifndef CLIMB_DISPLAY_H +#define CLIMB_DISPLAY_H + +#include "grade_colors.h" +#include "qr_generator.h" + +#include + +#ifdef ENABLE_DISPLAY +#include + +// ============================================ +// LilyGo T-Display S3 Pin Configuration +// ============================================ + +// Parallel 8-bit data pins +#define LCD_D0_PIN 39 +#define LCD_D1_PIN 40 +#define LCD_D2_PIN 41 +#define LCD_D3_PIN 42 +#define LCD_D4_PIN 45 +#define LCD_D5_PIN 46 +#define LCD_D6_PIN 47 +#define LCD_D7_PIN 48 + +// Control pins +#define LCD_WR_PIN 8 // Write strobe +#define LCD_RD_PIN 9 // Read strobe +#define LCD_RS_PIN 7 // Register select (DC) +#define LCD_CS_PIN 6 // Chip select +#define LCD_RST_PIN 5 // Reset + +// Backlight and power +#define LCD_BL_PIN 38 +#define LCD_POWER_PIN 15 + +// Custom LGFX class for T-Display-S3 +class LGFX_TDisplayS3 : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel_instance; + lgfx::Bus_Parallel8 _bus_instance; + lgfx::Light_PWM _light_instance; + + public: + LGFX_TDisplayS3(); +}; +#endif + +// T-Display-S3 screen dimensions (portrait mode) +#define DISPLAY_WIDTH 170 +#define DISPLAY_HEIGHT 320 + +// Layout constants +#define HEADER_HEIGHT 24 +#define CURRENT_CLIMB_HEIGHT 90 +#define HISTORY_HEADER_HEIGHT 20 +#define HISTORY_ITEM_HEIGHT 28 +#define STATUS_BAR_HEIGHT 24 +#define QR_SIZE 80 + +// WiFi/BLE status +enum class WiFiStatus { DISCONNECTED, CONNECTING, CONNECTED }; + +enum class BLEStatus { IDLE, ADVERTISING, CONNECTED, PROXY_CONNECTED }; + +/** + * ClimbDisplay manages the T-Display-S3 screen showing: + * - Current climb name and grade (with QR code) + * - Recent climb history (last 5) + * - Status bar (WiFi, BLE, proxy) + */ +class ClimbDisplay { + public: + ClimbDisplay(); + + /** + * Initialize the display. + */ + void begin(); + + /** + * Set the current climb to display. + */ + void setCurrentClimb(const char* name, const char* grade, const char* uuid, const char* boardPath, + const char* sessionId); + + /** + * Clear the current climb (show "No climb selected"). + */ + void clearCurrentClimb(); + + /** + * Update WiFi status indicator. + */ + void setWiFiStatus(WiFiStatus status); + + /** + * Update BLE status indicator. + */ + void setBLEStatus(BLEStatus status); + + /** + * Set display brightness. + * @param brightness 0-255 + */ + void setBrightness(uint8_t brightness); + + /** + * Get current brightness. + */ + uint8_t getBrightness() const; + + /** + * Force a full redraw of the display. + */ + void redraw(); + + /** + * Update the display (call periodically). + */ + void loop(); + + private: +#ifdef ENABLE_DISPLAY + LGFX_TDisplayS3 lcd; // Direct member (not pointer) +#endif + bool initialized; + uint8_t brightness; + WiFiStatus wifiStatus; + BLEStatus bleStatus; + + // Current climb info + char currentName[64]; + char currentGrade[16]; + bool hasCurrentClimb; + + // Redraw flags + bool needsFullRedraw; + bool needsStatusRedraw; + bool needsCurrentClimbRedraw; + bool needsHistoryRedraw; + + // Drawing methods + void drawHeader(); + void drawCurrentClimb(); + void drawQRCode(int x, int y); + void drawHistoryHeader(); + void drawHistoryList(); + void drawHistoryItem(int index, int y); + void drawStatusBar(); + void drawBackground(); + + // Helper methods + void truncateText(const char* input, char* output, int maxChars); +}; + +extern ClimbDisplay Display; + +#endif diff --git a/embedded/libs/display-ui/src/display_ui.h b/embedded/libs/display-ui/src/display_ui.h new file mode 100644 index 00000000..5ddd0c58 --- /dev/null +++ b/embedded/libs/display-ui/src/display_ui.h @@ -0,0 +1,34 @@ +#ifndef DISPLAY_UI_H +#define DISPLAY_UI_H + +/** + * Display UI Library for T-Display-S3 + * + * This library provides a complete UI for displaying climb information + * on the LilyGo T-Display-S3 (170x320 LCD). + * + * Components: + * - ClimbDisplay: Main display manager + * - QRGenerator: QR code generation for Boardsesh URLs + * - Grade colors: V-grade color scheme in RGB565 + * + * Usage: + * #include + * + * void setup() { + * Display.begin(); + * } + * + * void loop() { + * Display.loop(); + * } + * + * // When climb changes: + * Display.setCurrentClimb("Climb Name", "V5", "uuid", "kilter/1/12/1,2,3/40", "session123"); + */ + +#include "climb_display.h" +#include "grade_colors.h" +#include "qr_generator.h" + +#endif diff --git a/embedded/libs/display-ui/src/grade_colors.h b/embedded/libs/display-ui/src/grade_colors.h new file mode 100644 index 00000000..c2f93e1e --- /dev/null +++ b/embedded/libs/display-ui/src/grade_colors.h @@ -0,0 +1,180 @@ +#ifndef GRADE_COLORS_H +#define GRADE_COLORS_H + +#include + +/** + * V-grade color scheme based on thecrag.com grade coloring + * Colors are in RGB565 format for TFT displays + * + * Color progression from yellow (easy) to purple (hard): + * - V0: Yellow + * - V1-V2: Orange + * - V3-V4: Dark orange/red-orange + * - V5-V6: Red + * - V7-V10: Dark red/crimson + * - V11+: Purple/magenta + */ + +// RGB565 color format: 5 bits red, 6 bits green, 5 bits blue +// Macro to convert RGB888 to RGB565 +#define RGB565(r, g, b) (((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)) + +// V-grade colors in RGB565 format +#define V_GRADE_COLOR_V0 RGB565(0xFF, 0xEB, 0x3B) // Yellow #FFEB3B +#define V_GRADE_COLOR_V1 RGB565(0xFF, 0xC1, 0x07) // Amber #FFC107 +#define V_GRADE_COLOR_V2 RGB565(0xFF, 0x98, 0x00) // Orange #FF9800 +#define V_GRADE_COLOR_V3 RGB565(0xFF, 0x70, 0x43) // Deep orange #FF7043 +#define V_GRADE_COLOR_V4 RGB565(0xFF, 0x57, 0x22) // Red-orange #FF5722 +#define V_GRADE_COLOR_V5 RGB565(0xF4, 0x43, 0x36) // Red #F44336 +#define V_GRADE_COLOR_V6 RGB565(0xE5, 0x39, 0x35) // Red darker #E53935 +#define V_GRADE_COLOR_V7 RGB565(0xD3, 0x2F, 0x2F) // Dark red #D32F2F +#define V_GRADE_COLOR_V8 RGB565(0xC6, 0x28, 0x28) // Darker red #C62828 +#define V_GRADE_COLOR_V9 RGB565(0xB7, 0x1C, 0x1C) // Deep red #B71C1C +#define V_GRADE_COLOR_V10 RGB565(0xA1, 0x1B, 0x4A) // Red-purple #A11B4A +#define V_GRADE_COLOR_V11 RGB565(0x9C, 0x27, 0xB0) // Purple #9C27B0 +#define V_GRADE_COLOR_V12 RGB565(0x7B, 0x1F, 0xA2) // Dark purple #7B1FA2 +#define V_GRADE_COLOR_V13 RGB565(0x6A, 0x1B, 0x9A) // Darker purple #6A1B9A +#define V_GRADE_COLOR_V14 RGB565(0x5C, 0x1A, 0x87) // Deep purple #5C1A87 +#define V_GRADE_COLOR_V15 RGB565(0x4A, 0x14, 0x8C) // Very deep purple #4A148C +#define V_GRADE_COLOR_V16 RGB565(0x38, 0x00, 0x6B) // Near black purple #38006B +#define V_GRADE_COLOR_V17 RGB565(0x2A, 0x00, 0x54) // Darkest purple #2A0054 + +// Default color for unknown grades +#define V_GRADE_COLOR_DEFAULT RGB565(0xC8, 0xC8, 0xC8) // Light gray + +// Common display colors +#define COLOR_WHITE 0xFFFF +#define COLOR_BLACK 0x0000 +#define COLOR_DARK_GRAY RGB565(0x40, 0x40, 0x40) +#define COLOR_LIGHT_GRAY RGB565(0xA0, 0xA0, 0xA0) +#define COLOR_GREEN RGB565(0x00, 0xD9, 0x64) +#define COLOR_RED RGB565(0xE9, 0x45, 0x60) +#define COLOR_CYAN RGB565(0x00, 0xD9, 0xFF) + +/** + * Get RGB565 color for a V-grade string. + * Supports formats: "V0", "V5", "V10", "v3", "6c/V5" (extracts V-grade) + * + * @param grade Grade string + * @return RGB565 color value + */ +inline uint16_t getVGradeColor(const char* grade) { + if (!grade || grade[0] == '\0') { + return V_GRADE_COLOR_DEFAULT; + } + + // Find V-grade in string (e.g., extract "V5" from "6c/V5") + const char* vPos = strchr(grade, 'V'); + if (!vPos) { + vPos = strchr(grade, 'v'); + } + if (!vPos) { + return V_GRADE_COLOR_DEFAULT; + } + + // Parse the number after V + int vGrade = atoi(vPos + 1); + + switch (vGrade) { + case 0: + return V_GRADE_COLOR_V0; + case 1: + return V_GRADE_COLOR_V1; + case 2: + return V_GRADE_COLOR_V2; + case 3: + return V_GRADE_COLOR_V3; + case 4: + return V_GRADE_COLOR_V4; + case 5: + return V_GRADE_COLOR_V5; + case 6: + return V_GRADE_COLOR_V6; + case 7: + return V_GRADE_COLOR_V7; + case 8: + return V_GRADE_COLOR_V8; + case 9: + return V_GRADE_COLOR_V9; + case 10: + return V_GRADE_COLOR_V10; + case 11: + return V_GRADE_COLOR_V11; + case 12: + return V_GRADE_COLOR_V12; + case 13: + return V_GRADE_COLOR_V13; + case 14: + return V_GRADE_COLOR_V14; + case 15: + return V_GRADE_COLOR_V15; + case 16: + return V_GRADE_COLOR_V16; + case 17: + return V_GRADE_COLOR_V17; + default: + if (vGrade > 17) + return V_GRADE_COLOR_V17; + return V_GRADE_COLOR_DEFAULT; + } +} + +/** + * Check if a color is light (for determining text color contrast) + * + * @param color RGB565 color + * @return true if color is light (should use dark text) + */ +inline bool isLightColor(uint16_t color) { + // Extract RGB components from RGB565 + uint8_t r = (color >> 11) << 3; + uint8_t g = ((color >> 5) & 0x3F) << 2; + uint8_t b = (color & 0x1F) << 3; + + // Calculate relative luminance + float luminance = (0.299f * r + 0.587f * g + 0.114f * b) / 255.0f; + return luminance > 0.5f; +} + +/** + * Get appropriate text color (black or white) for a background color + * + * @param backgroundColor RGB565 background color + * @return RGB565 color for text (black or white) + */ +inline uint16_t getTextColorForBackground(uint16_t backgroundColor) { + return isLightColor(backgroundColor) ? COLOR_BLACK : COLOR_WHITE; +} + +/** + * Extract V-grade string from a difficulty string. + * Examples: "6c/V5" -> "V5", "V10" -> "V10", "7a" -> "" + * + * @param difficulty Full difficulty string + * @param output Buffer to store extracted V-grade + * @param maxLen Maximum length of output buffer + */ +inline void extractVGrade(const char* difficulty, char* output, size_t maxLen) { + output[0] = '\0'; + if (!difficulty) + return; + + const char* vPos = strchr(difficulty, 'V'); + if (!vPos) { + vPos = strchr(difficulty, 'v'); + } + if (!vPos) + return; + + // Copy the V-grade (V followed by digits) + size_t i = 0; + output[i++] = 'V'; + vPos++; + while (*vPos >= '0' && *vPos <= '9' && i < maxLen - 1) { + output[i++] = *vPos++; + } + output[i] = '\0'; +} + +#endif diff --git a/embedded/libs/display-ui/src/qr_generator.cpp b/embedded/libs/display-ui/src/qr_generator.cpp new file mode 100644 index 00000000..dac0dfe3 --- /dev/null +++ b/embedded/libs/display-ui/src/qr_generator.cpp @@ -0,0 +1,85 @@ +#include "qr_generator.h" + +#include +#include // ricmoo/QRCode library + +QRGenerator QRCode_Gen; + +// Static QRCode object for the library +static QRCode qrcode; + +QRGenerator::QRGenerator() : qrSize(0), valid(false) { + urlBuffer[0] = '\0'; + memset(qrData, 0, sizeof(qrData)); +} + +bool QRGenerator::generate(const char* sessionId) { + clear(); + + if (!sessionId || strlen(sessionId) == 0) { + Logger.logln("QRGen: Missing sessionId"); + return false; + } + + // Build the URL + // Format: https://boardsesh.com/join/{sessionId} + int len = snprintf(urlBuffer, sizeof(urlBuffer), "https://boardsesh.com/join/%s", sessionId); + + if (len >= (int)sizeof(urlBuffer)) { + Logger.logln("QRGen: URL too long (%d chars)", len); + return false; + } + + // Check if URL fits in QR code + if (len > QR_MAX_DATA_SIZE) { + Logger.logln("QRGen: URL exceeds QR capacity (%d > %d)", len, QR_MAX_DATA_SIZE); + return false; + } + + Logger.logln("QRGen: Generating QR for %s", urlBuffer); + + // Generate QR code + qrcode_initText(&qrcode, qrData, QR_VERSION, ECC_LOW, urlBuffer); + qrSize = qrcode.size; + valid = true; + + Logger.logln("QRGen: Generated %dx%d QR code", qrSize, qrSize); + return true; +} + +void QRGenerator::clear() { + valid = false; + qrSize = 0; + urlBuffer[0] = '\0'; + memset(qrData, 0, sizeof(qrData)); +} + +bool QRGenerator::isValid() const { + return valid; +} + +const uint8_t* QRGenerator::getData() const { + return qrData; +} + +int QRGenerator::getSize() const { + return qrSize; +} + +bool QRGenerator::getModule(int x, int y) const { + if (!valid || x < 0 || y < 0 || x >= qrSize || y >= qrSize) { + return false; + } + return qrcode_getModule(&qrcode, x, y); +} + +int QRGenerator::getPixelSize() const { + if (!valid) + return 0; + // Add 2 modules of quiet zone on each side + return (qrSize + 4) * QR_MODULE_SIZE; +} + +const char* QRGenerator::getUrl() const { + return urlBuffer; +} diff --git a/embedded/libs/display-ui/src/qr_generator.h b/embedded/libs/display-ui/src/qr_generator.h new file mode 100644 index 00000000..0f1718bc --- /dev/null +++ b/embedded/libs/display-ui/src/qr_generator.h @@ -0,0 +1,82 @@ +#ifndef QR_GENERATOR_H +#define QR_GENERATOR_H + +#include + +// QR code version settings +#define QR_VERSION 4 // Version 4 can hold ~78 alphanumeric chars with ECC_LOW +#define QR_MODULE_SIZE 2 // Size of each QR module in pixels +#define QR_MAX_DATA_SIZE 78 // Max data for Version 4 with ECC_LOW + +/** + * QRGenerator creates QR codes for Boardsesh session join URLs. + * + * URL format: https://boardsesh.com/join/{sessionId} + * + * Example: + * sessionId: xyz789-abc123 + * Result: https://boardsesh.com/join/xyz789-abc123 + */ +class QRGenerator { + public: + QRGenerator(); + + /** + * Generate QR data for a session join URL. + * + * @param sessionId Session ID for joining + * @return true if QR was generated successfully + */ + bool generate(const char* sessionId); + + /** + * Clear the QR data. + */ + void clear(); + + /** + * Check if QR data is valid. + */ + bool isValid() const; + + /** + * Get the QR code data buffer. + * This is the internal qrcode_t buffer from the QRCode library. + */ + const uint8_t* getData() const; + + /** + * Get the QR code size (modules per side). + */ + int getSize() const; + + /** + * Check if a specific module is set (black). + * + * @param x X coordinate (0 to size-1) + * @param y Y coordinate (0 to size-1) + * @return true if module is black + */ + bool getModule(int x, int y) const; + + /** + * Get the calculated QR code pixel size. + * Based on module size and border. + */ + int getPixelSize() const; + + /** + * Get the URL that was encoded. + */ + const char* getUrl() const; + + private: + uint8_t qrData[128]; // QRCode library data buffer (Version 4) + char urlBuffer[100]; // Buffer for the full URL + int qrSize; + bool valid; +}; + +extern QRGenerator QRCode_Gen; + +#endif diff --git a/embedded/projects/climb-queue-display/README.md b/embedded/projects/climb-queue-display/README.md deleted file mode 100644 index 8ca06002..00000000 --- a/embedded/projects/climb-queue-display/README.md +++ /dev/null @@ -1,154 +0,0 @@ -# Boardsesh Climb Queue Display - -ESP32 firmware for displaying climb queue information on a LilyGo T-Display S3. - -## Hardware - -- **Device**: LilyGo T-Display S3 -- **Display**: 1.9" TFT LCD, 170x320 pixels, ST7789 driver -- **Interface**: Parallel 8-bit bus -- **MCU**: ESP32-S3 with WiFi/BLE -- **Buttons**: GPIO 0 (Boot) and GPIO 14 (User) - -## Features - -1. **Current Climb Display** - Shows climb name and grade with colored badge -2. **Previous Climbs History** - Shows last 3-5 climbs with grade colors -3. **QR Code** - Scan to open current climb in official Kilter/Tension app -4. **BLE Proxy Mode** - Forward LED commands to official boards (optional) - -## Display Layout (170x320 Portrait) - -``` -┌─────────────────────┐ Y=0 -│ WiFi ● WS ● BLE ● │ Status bar (20px) -├─────────────────────┤ Y=20 -│ │ -│ Current Climb │ Climb name -│ ┌───────────┐ │ -│ │ V3 │ │ Grade badge (colored) -│ └───────────┘ │ -├─────────────────────┤ Y=140 -│ Open in App │ -│ ┌─────────┐ │ -│ │ QR CODE │ │ 100x100 pixels -│ │ │ │ -│ └─────────┘ │ -├─────────────────────┤ Y=255 -│ Previous: │ -│ ● Climb A V2 │ History (3 items) -│ ● Climb B V4 │ -│ ● Climb C V1 │ -└─────────────────────┘ Y=320 -``` - -## Building - -### Prerequisites - -1. Install [PlatformIO](https://platformio.org/) -2. Clone this repository - -### Build - -```bash -cd embedded/projects/climb-queue-display -pio run -``` - -### Upload - -```bash -pio run -t upload -``` - -### Monitor Serial Output - -```bash -pio device monitor -``` - -## Configuration - -The device hosts a web configuration portal on port 80 when connected to WiFi. - -### Required Settings - -| Setting | Description | -|---------|-------------| -| `api_key` | Controller API key from Boardsesh web app | -| `session_id` | Party session ID to subscribe to | - -### Optional Settings - -| Setting | Default | Description | -|---------|---------|-------------| -| `board_type` | `kilter` | Board type: `kilter` or `tension` | -| `ble_proxy_enabled` | `false` | Enable BLE proxy mode | -| `ble_board_address` | (auto) | Saved board BLE address | -| `backend_host` | `boardsesh.com` | Backend server hostname | -| `backend_port` | `443` | Backend server port | -| `backend_path` | `/graphql` | GraphQL endpoint path | - -## BLE Proxy Mode - -When enabled, the device can forward LED commands to an official Kilter or Tension board: - -1. Enable `ble_proxy_enabled` in config -2. The device will scan for nearby Aurora boards -3. Once connected, LED commands from Boardsesh are forwarded to the board -4. The board address is saved for automatic reconnection - -## Grade Colors - -Grade colors are sent from the Boardsesh backend as hex strings (e.g., `#FF7043`). The firmware converts these to RGB565 for display. This keeps colors in sync with the web UI automatically. - -Color progression: -- **V0-V2**: Yellow to Orange -- **V3-V4**: Orange-red -- **V5-V6**: Red -- **V7-V10**: Dark red to purple -- **V11+**: Purple shades - -## App URL QR Codes - -The QR code links to the climb in the official app: -- **Kilter**: `https://kilterboardapp.com/climbs/{uuid}` -- **Tension**: `https://tensionboardapp2.com/climbs/{uuid}` - -## Dependencies - -- [LovyanGFX](https://github.com/lovyan03/LovyanGFX) - Display driver -- [qrcode](https://github.com/ricmoo/QRCode) - QR code generation -- [NimBLE-Arduino](https://github.com/h2zero/NimBLE-Arduino) - BLE stack -- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) - JSON parsing -- [WebSockets](https://github.com/Links2004/arduinoWebSockets) - WebSocket client - -Plus shared Boardsesh libraries: -- config-manager -- log-buffer -- wifi-utils -- graphql-ws-client -- esp-web-server -- aurora-ble-client - -## Troubleshooting - -### Display shows "Configure WiFi" -Connect to the device's AP and configure WiFi credentials. - -### Display shows "Configure API key" -1. Go to Boardsesh web app -2. Register a controller device -3. Copy the API key to device config - -### Display shows "Configure session" -Set the `session_id` to a valid party session ID. - -### QR code not showing -The QR code only appears when there's a current climb with a valid UUID. - -### BLE not connecting -- Ensure `ble_proxy_enabled` is true -- Check that the board is powered and not connected to another device -- Try clearing `ble_board_address` to force a new scan diff --git a/embedded/projects/climb-queue-display/partitions.csv b/embedded/projects/climb-queue-display/partitions.csv deleted file mode 100644 index 39810d67..00000000 --- a/embedded/projects/climb-queue-display/partitions.csv +++ /dev/null @@ -1,6 +0,0 @@ -# Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, 0x9000, 0x5000, -otadata, data, ota, 0xe000, 0x2000, -app0, app, ota_0, 0x10000, 0x400000, -app1, app, ota_1, 0x410000,0x400000, -spiffs, data, spiffs, 0x810000,0x7F0000, diff --git a/embedded/projects/climb-queue-display/platformio.ini b/embedded/projects/climb-queue-display/platformio.ini deleted file mode 100644 index f195b44c..00000000 --- a/embedded/projects/climb-queue-display/platformio.ini +++ /dev/null @@ -1,64 +0,0 @@ -; PlatformIO Project Configuration File -; -; Boardsesh Climb Queue Display Firmware -; For LilyGo T-Display S3 (170x320 ST7789) -; https://www.lilygo.cc/products/t-display-s3 - -[platformio] -default_envs = lilygo_t_display_s3 - -[env] -platform = espressif32 -framework = arduino -monitor_speed = 115200 -upload_speed = 921600 - -; Shared local libraries (symlink) -lib_deps = - config-manager=symlink://../../libs/config-manager - log-buffer=symlink://../../libs/log-buffer - wifi-utils=symlink://../../libs/wifi-utils - graphql-ws-client=symlink://../../libs/graphql-ws-client - esp-web-server=symlink://../../libs/esp-web-server - lilygo-display=symlink://../../libs/lilygo-display - ; BLE client library for proxy mode - disabled for now due to NimBLE compilation issues - ; TODO: Re-enable once NimBLE API compatibility is fixed - ; aurora-ble-client=symlink://../../libs/aurora-ble-client - ; External libraries from PlatformIO registry - bblanchon/ArduinoJson@^7.0.0 - links2004/WebSockets@^2.4.0 - ; LovyanGFX for hardware display driver - lovyan03/LovyanGFX@^1.1.12 - ; NimBLE for BLE proxy mode - disabled for now - ; h2zero/NimBLE-Arduino@^1.4.0 - ; QR code generation - ricmoo/qrcode@^0.0.1 - -build_flags = - -D CORE_DEBUG_LEVEL=3 - ; Enable PSRAM - -D BOARD_HAS_PSRAM - -mfix-esp32-psram-cache-issue - -; LilyGo T-Display S3 (170x320 ST7789) -; Using esp32-s3-devkitc-1 board definition for better PSRAM/SSL compatibility -[env:lilygo_t_display_s3] -board = esp32-s3-devkitc-1 -board_build.mcu = esp32s3 -board_build.f_cpu = 240000000L -board_build.arduino.memory_type = qio_opi -board_build.flash_mode = qio -board_build.psram_type = opi -board_upload.flash_size = 16MB -board_upload.maximum_size = 16777216 - -build_flags = - ${env.build_flags} - -D ARDUINO_USB_CDC_ON_BOOT=1 - -D ARDUINO_USB_MODE=1 - ; LilyGo T-Display S3 specific - -D TFT_WIDTH=170 - -D TFT_HEIGHT=320 - -; Partition table for firmware + NVS -board_build.partitions = partitions.csv diff --git a/embedded/projects/climb-queue-display/src/config/display_config.h b/embedded/projects/climb-queue-display/src/config/display_config.h deleted file mode 100644 index a06d27df..00000000 --- a/embedded/projects/climb-queue-display/src/config/display_config.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef DISPLAY_CONFIG_H -#define DISPLAY_CONFIG_H - -// ============================================ -// Device Identification -// ============================================ - -#define DEVICE_NAME "Boardsesh Queue Display" -#define FIRMWARE_VERSION "1.1.0" - -// ============================================ -// Backend Configuration Defaults -// ============================================ - -#define DEFAULT_BACKEND_HOST "boardsesh.com" -#define DEFAULT_BACKEND_PORT 443 -#define DEFAULT_BACKEND_PATH "/graphql" - -// ============================================ -// BLE Configuration -// ============================================ - -#define BLE_SCAN_TIMEOUT_SEC 30 -#define BLE_RECONNECT_INTERVAL_MS 30000 - -// ============================================ -// App URL Prefixes for QR Codes -// ============================================ - -#define KILTER_APP_URL_PREFIX "https://kilterboardapp.com/climbs/" -#define TENSION_APP_URL_PREFIX "https://tensionboardapp2.com/climbs/" - -// ============================================ -// Debug Options -// ============================================ - -#define DEBUG_SERIAL true -#define DEBUG_DISPLAY false -#define DEBUG_GRAPHQL false - -#endif // DISPLAY_CONFIG_H diff --git a/embedded/projects/climb-queue-display/src/main.cpp b/embedded/projects/climb-queue-display/src/main.cpp deleted file mode 100644 index 7f34898d..00000000 --- a/embedded/projects/climb-queue-display/src/main.cpp +++ /dev/null @@ -1,693 +0,0 @@ -#include - -// Shared libraries -#include -#include - -#include -#include -#include -#include - -// BLE client for proxy mode - disabled for now due to NimBLE API issues -// TODO: Re-enable once aurora-ble-client is updated for NimBLE 1.4.x -// #include -#define BLE_PROXY_DISABLED 1 - -// Project configuration -#include "config/display_config.h" - -// ============================================ -// State -// ============================================ - -bool wifiConnected = false; -bool backendConnected = false; -String boardType = "kilter"; - -#if !BLE_PROXY_DISABLED -bool bleConnected = false; -bool bleProxyEnabled = false; -unsigned long lastBleScanTime = 0; -const unsigned long BLE_SCAN_INTERVAL = 30000; -std::vector currentLedCommands; -#endif - -unsigned long lastDisplayUpdate = 0; -const unsigned long DISPLAY_UPDATE_INTERVAL = 100; -const int MAX_LED_COMMANDS = 500; - -// Current climb data -String currentClimbUuid = ""; -String currentClimbName = ""; -String currentGrade = ""; -String currentGradeColor = ""; -String currentQueueItemUuid = ""; // Queue item UUID for navigation -bool hasCurrentClimb = false; - -// ============================================ -// Forward Declarations -// ============================================ - -void onWiFiStateChange(WiFiConnectionState state); -void onGraphQLStateChange(GraphQLConnectionState state); -void onGraphQLMessage(JsonDocument& doc); -void onQueueSync(const ControllerQueueSyncData& data); -void handleLedUpdate(JsonObject& data); -void navigatePrevious(); -void navigateNext(); -void sendNavigationMutation(const char* queueItemUuid); - -#if !BLE_PROXY_DISABLED -void onBleConnect(bool connected, const char* deviceName); -void onBleScan(const char* deviceName, const char* address); -void forwardToBoard(); -#endif - -// ============================================ -// Setup -// ============================================ - -void setup() { - Serial.begin(115200); - delay(1000); - - Logger.logln("================================="); - Logger.logln("%s v%s", DEVICE_NAME, FIRMWARE_VERSION); - Logger.logln("LilyGo T-Display S3 (170x320)"); - Logger.logln("================================="); - - // Initialize config manager first (needed for display settings) - Config.begin(); - - // Initialize display - Logger.logln("Initializing display..."); - if (!Display.begin()) { - Logger.logln("ERROR: Display initialization failed!"); - while (1) - delay(1000); - } - - Display.showConnecting(); - - // Load board type - boardType = Config.getString("board_type", "kilter"); - - // Initialize WiFi - Logger.logln("Initializing WiFi..."); - WiFiMgr.begin(); - WiFiMgr.setStateCallback(onWiFiStateChange); - - // Try to connect to saved WiFi - if (!WiFiMgr.connectSaved()) { - Logger.logln("No saved WiFi credentials - configure via web server"); - Display.showError("Configure WiFi"); - } - - // Initialize web config server - Logger.logln("Starting web server..."); - WebConfig.begin(); - - // Initialize BLE client for proxy mode -#if !BLE_PROXY_DISABLED - bleProxyEnabled = Config.getBool("ble_proxy_enabled", false); - if (bleProxyEnabled) { - Logger.logln("Initializing BLE client for proxy mode..."); - BLEClient.begin(); - BLEClient.setConnectCallback(onBleConnect); - BLEClient.setScanCallback(onBleScan); - - String savedBoardAddress = Config.getString("ble_board_address"); - if (savedBoardAddress.length() > 0) { - Logger.logln("Connecting to saved board: %s", savedBoardAddress.c_str()); - BLEClient.connect(savedBoardAddress.c_str()); - } else { - BLEClient.setAutoConnect(true); - BLEClient.startScan(BLE_SCAN_TIMEOUT_SEC); - } - } - Display.setBleStatus(bleProxyEnabled, false); -#else - Logger.logln("BLE proxy mode disabled in this build"); - Display.setBleStatus(false, false); -#endif - - Logger.logln("Setup complete!"); -} - -// ============================================ -// Main Loop -// ============================================ - -void loop() { - // Process WiFi - WiFiMgr.loop(); - - // Process WebSocket if WiFi connected - if (wifiConnected) { - GraphQL.loop(); - } - - // Process web server (works in both AP and STA mode) - WebConfig.loop(); - - // Process BLE client if proxy mode enabled -#if !BLE_PROXY_DISABLED - if (bleProxyEnabled) { - BLEClient.loop(); - - // Retry scanning if not connected - unsigned long now = millis(); - if (!bleConnected && !BLEClient.isScanning() && now - lastBleScanTime > BLE_SCAN_INTERVAL) { - Logger.logln("Retrying BLE scan..."); - BLEClient.startScan(15); - if (BLEClient.isScanning()) { - lastBleScanTime = now; - } - } - } -#endif - - // Handle button presses with debouncing - static bool lastButton1 = HIGH; - static bool lastButton2 = HIGH; - static unsigned long button1PressTime = 0; - static unsigned long button2PressTime = 0; - static bool button1LongPressTriggered = false; - static const unsigned long DEBOUNCE_MS = 50; - static const unsigned long LONG_PRESS_MS = 3000; - static const unsigned long SHORT_PRESS_MAX_MS = 500; - - bool button1 = digitalRead(BUTTON_1_PIN); - bool button2 = digitalRead(BUTTON_2_PIN); - unsigned long now = millis(); - - // Button 1: Short press = previous, Long press (3s) = reset config - if (button1 == LOW && lastButton1 == HIGH) { - // Button just pressed - button1PressTime = now; - button1LongPressTriggered = false; - } else if (button1 == LOW && button1PressTime > 0) { - // Button held down - check for long press - if (!button1LongPressTriggered && now - button1PressTime > LONG_PRESS_MS) { - Logger.logln("Button 1 long press - resetting configuration..."); - Display.showError("Resetting..."); - button1LongPressTriggered = true; - - Config.setString("wifi_ssid", ""); - Config.setString("wifi_pass", ""); - Config.setString("api_key", ""); - Config.setString("session_id", ""); - - delay(1000); - ESP.restart(); - } - } else if (button1 == HIGH && lastButton1 == LOW) { - // Button just released - check for short press (navigate previous) - if (!button1LongPressTriggered && button1PressTime > 0) { - unsigned long pressDuration = now - button1PressTime; - if (pressDuration > DEBOUNCE_MS && pressDuration < SHORT_PRESS_MAX_MS) { - Logger.logln("Button 1 short press - navigate previous"); - navigatePrevious(); - } - } - button1PressTime = 0; - } - lastButton1 = button1; - - // Button 2: Short press = next - if (button2 == LOW && lastButton2 == HIGH) { - // Button just pressed - button2PressTime = now; - } else if (button2 == HIGH && lastButton2 == LOW) { - // Button just released - check for short press (navigate next) - if (button2PressTime > 0) { - unsigned long pressDuration = now - button2PressTime; - if (pressDuration > DEBOUNCE_MS && pressDuration < SHORT_PRESS_MAX_MS) { - Logger.logln("Button 2 short press - navigate next"); - navigateNext(); - } - } - button2PressTime = 0; - } - lastButton2 = button2; -} - -// ============================================ -// WiFi Callbacks -// ============================================ - -void onWiFiStateChange(WiFiConnectionState state) { - switch (state) { - case WiFiConnectionState::CONNECTED: { - Logger.logln("WiFi connected: %s", WiFiMgr.getIP().c_str()); - wifiConnected = true; - Display.setWiFiStatus(true); - - // Get backend config - String host = Config.getString("backend_host", DEFAULT_BACKEND_HOST); - int port = Config.getInt("backend_port", DEFAULT_BACKEND_PORT); - String path = Config.getString("backend_path", DEFAULT_BACKEND_PATH); - String apiKey = Config.getString("api_key"); - - if (apiKey.length() == 0) { - Logger.logln("No API key configured"); - Display.showError("Configure API key", WiFiMgr.getIP().c_str()); - break; - } - - String sessionId = Config.getString("session_id"); - if (sessionId.length() == 0) { - Logger.logln("No session ID configured"); - Display.showError("Configure session", WiFiMgr.getIP().c_str()); - break; - } - - Logger.logln("Connecting to backend: %s:%d%s", host.c_str(), port, path.c_str()); - Display.showConnecting(); - - GraphQL.setStateCallback(onGraphQLStateChange); - GraphQL.setMessageCallback(onGraphQLMessage); - GraphQL.setQueueSyncCallback(onQueueSync); - GraphQL.begin(host.c_str(), port, path.c_str(), apiKey.c_str()); - break; - } - - case WiFiConnectionState::DISCONNECTED: - Logger.logln("WiFi disconnected"); - wifiConnected = false; - backendConnected = false; - Display.setWiFiStatus(false); - Display.setBackendStatus(false); - Display.showConnecting(); - break; - - case WiFiConnectionState::CONNECTING: - Logger.logln("WiFi connecting..."); - break; - - case WiFiConnectionState::CONNECTION_FAILED: - Logger.logln("WiFi connection failed"); - Display.showError("WiFi failed"); - break; - } -} - -// ============================================ -// GraphQL Callbacks -// ============================================ - -void onGraphQLStateChange(GraphQLConnectionState state) { - switch (state) { - case GraphQLConnectionState::CONNECTION_ACK: { - Logger.logln("Backend connected!"); - backendConnected = true; - Display.setBackendStatus(true); - - String sessionId = Config.getString("session_id"); - if (sessionId.length() == 0) { - Logger.logln("No session ID configured"); - Display.showError("Configure session"); - break; - } - - String variables = "{\"sessionId\":\"" + sessionId + "\"}"; - GraphQL.subscribe("controller-events", - "subscription ControllerEvents($sessionId: ID!) { " - "controllerEvents(sessionId: $sessionId) { " - "... on LedUpdate { __typename commands { position r g b } queueItemUuid climbUuid climbName " - "climbGrade gradeColor boardPath angle " - "navigation { previousClimbs { name grade gradeColor } " - "nextClimb { name grade gradeColor } currentIndex totalCount } } " - "... on ControllerQueueSync { __typename queue { uuid climbUuid name grade gradeColor } currentIndex } " - "... on ControllerPing { __typename timestamp } " - "} }", - variables.c_str()); - - hasCurrentClimb = false; - Display.showNoClimb(); - break; - } - - case GraphQLConnectionState::SUBSCRIBED: - Logger.logln("Subscribed to session updates"); - break; - - case GraphQLConnectionState::DISCONNECTED: - Logger.logln("Backend disconnected"); - backendConnected = false; - Display.setBackendStatus(false); - Display.showConnecting(); - break; - - default: - break; - } -} - -// ============================================ -// GraphQL Message Handler -// ============================================ - -void onGraphQLMessage(JsonDocument& doc) { - JsonObject payloadObj = doc["payload"]; - if (payloadObj["data"].is()) { - JsonObject data = payloadObj["data"]; - if (data["controllerEvents"].is()) { - JsonObject event = data["controllerEvents"]; - const char* typename_ = event["__typename"]; - - if (typename_ && strcmp(typename_, "LedUpdate") == 0) { - handleLedUpdate(event); - } - } - } -} - -// ============================================ -// Queue Sync Callback -// ============================================ - -void onQueueSync(const ControllerQueueSyncData& data) { - Logger.logln("Queue sync: %d items, currentIndex: %d", data.count, data.currentIndex); - - // Convert to LocalQueueItem array - LocalQueueItem items[ControllerQueueSyncData::MAX_ITEMS]; - for (int i = 0; i < data.count && i < MAX_QUEUE_SIZE; i++) { - strncpy(items[i].uuid, data.items[i].uuid, sizeof(items[i].uuid) - 1); - items[i].uuid[sizeof(items[i].uuid) - 1] = '\0'; - - strncpy(items[i].climbUuid, data.items[i].climbUuid, sizeof(items[i].climbUuid) - 1); - items[i].climbUuid[sizeof(items[i].climbUuid) - 1] = '\0'; - - strncpy(items[i].name, data.items[i].name, sizeof(items[i].name) - 1); - items[i].name[sizeof(items[i].name) - 1] = '\0'; - - strncpy(items[i].grade, data.items[i].grade, sizeof(items[i].grade) - 1); - items[i].grade[sizeof(items[i].grade) - 1] = '\0'; - - // Convert hex color to RGB565 if provided - if (data.items[i].gradeColor[0] == '#') { - items[i].gradeColorRgb = Display.getDisplay().color565( - strtol(String(data.items[i].gradeColor).substring(1, 3).c_str(), NULL, 16), - strtol(String(data.items[i].gradeColor).substring(3, 5).c_str(), NULL, 16), - strtol(String(data.items[i].gradeColor).substring(5, 7).c_str(), NULL, 16)); - } else { - items[i].gradeColorRgb = 0xFFFF; // White default - } - } - - // Update display queue state - Display.setQueueFromSync(items, data.count, data.currentIndex); - - Logger.logln("Queue sync complete: stored %d items, index %d", Display.getQueueCount(), - Display.getCurrentQueueIndex()); -} - -void handleLedUpdate(JsonObject& data) { - JsonArray commands = data["commands"]; - const char* queueItemUuid = data["queueItemUuid"]; - const char* climbUuid = data["climbUuid"]; - const char* climbName = data["climbName"]; - const char* climbGrade = data["climbGrade"]; - const char* boardPath = data["boardPath"]; - int angle = data["angle"] | 0; - int count = commands.isNull() ? 0 : commands.size(); - - Logger.logln("LED Update: %s [%s] @ %d degrees (%d holds), queueItemUuid: %s", climbName ? climbName : "(none)", - climbGrade ? climbGrade : "?", angle, count, queueItemUuid ? queueItemUuid : "(none)"); - - // Check if this confirms a pending navigation - if (Display.hasPendingNavigation() && queueItemUuid) { - const char* pendingUuid = Display.getPendingQueueItemUuid(); - if (pendingUuid && strcmp(queueItemUuid, pendingUuid) == 0) { - Logger.logln("LED Update confirms pending navigation to %s", queueItemUuid); - Display.clearPendingNavigation(); - // Display is already showing this climb from optimistic update - just update LEDs - } else { - Logger.logln("LED Update conflicts with pending navigation (expected %s, got %s)", pendingUuid, - queueItemUuid); - Display.clearPendingNavigation(); - // Fall through to update display with actual climb - } - } - - // Handle clear command - if (commands.isNull() || count == 0) { - if (hasCurrentClimb && currentClimbName.length() > 0) { - Display.addToHistory(currentClimbName.c_str(), currentGrade.c_str(), currentGradeColor.c_str()); - } - - hasCurrentClimb = false; - currentQueueItemUuid = ""; - currentClimbUuid = ""; - currentClimbName = ""; - currentGrade = ""; - currentGradeColor = ""; - - Display.showNoClimb(); - -#if !BLE_PROXY_DISABLED - currentLedCommands.clear(); - if (bleProxyEnabled && bleConnected) { - BLEClient.clearLeds(); - } -#endif - return; - } - - // Validate count - if (count > MAX_LED_COMMANDS) { - Logger.logln("WARNING: Received %d LED commands, limiting to %d", count, MAX_LED_COMMANDS); - count = MAX_LED_COMMANDS; - } - - // Add previous climb to history if different - if (hasCurrentClimb && currentClimbUuid.length() > 0 && climbUuid && String(climbUuid) != currentClimbUuid) { - Display.addToHistory(currentClimbName.c_str(), currentGrade.c_str(), currentGradeColor.c_str()); - } - -#if !BLE_PROXY_DISABLED - // Store LED commands for BLE forwarding - currentLedCommands.clear(); - currentLedCommands.reserve(count); - int i = 0; - for (JsonObject cmd : commands) { - if (i >= MAX_LED_COMMANDS) - break; - LedCommand ledCmd; - ledCmd.position = cmd["position"]; - ledCmd.r = cmd["r"]; - ledCmd.g = cmd["g"]; - ledCmd.b = cmd["b"]; - currentLedCommands.push_back(ledCmd); - i++; - } -#endif - - // Extract board type from boardPath (e.g., "kilter/1/12/1,2,3/40" -> "kilter") - String detectedBoardType = "kilter"; - if (boardPath) { - String bp = boardPath; - int slashPos = bp.indexOf('/'); - if (slashPos > 0) { - detectedBoardType = bp.substring(0, slashPos); - } - boardType = detectedBoardType; - } - - // Update state - currentQueueItemUuid = queueItemUuid ? queueItemUuid : ""; - currentClimbUuid = climbUuid ? climbUuid : ""; - currentClimbName = climbName ? climbName : ""; - currentGrade = climbGrade ? climbGrade : ""; - - // Parse gradeColor if present - const char* gradeColor = data["gradeColor"]; - currentGradeColor = gradeColor ? gradeColor : ""; - hasCurrentClimb = true; - - // Sync local queue index with backend if we have queueItemUuid - if (queueItemUuid && Display.getQueueCount() > 0) { - // Find this item in our local queue and update index - for (int i = 0; i < Display.getQueueCount(); i++) { - const LocalQueueItem* item = Display.getQueueItem(i); - if (item && strcmp(item->uuid, queueItemUuid) == 0) { - Display.setCurrentQueueIndex(i); - Logger.logln("LED Update: Synced local queue index to %d", i); - break; - } - } - } - - // Parse navigation context if present - if (data["navigation"].is()) { - JsonObject nav = data["navigation"]; - int currentIndex = nav["currentIndex"] | -1; - int totalCount = nav["totalCount"] | 0; - - // Parse previous climb (first in previousClimbs array = immediate previous) - QueueNavigationItem prevClimb; - if (nav["previousClimbs"].is()) { - JsonArray prevArray = nav["previousClimbs"]; - if (prevArray.size() > 0) { - JsonObject prev = prevArray[0]; - prevClimb = QueueNavigationItem(prev["name"] | "", prev["grade"] | "", prev["gradeColor"] | ""); - } - } - - // Parse next climb - QueueNavigationItem nextClimb; - if (nav["nextClimb"].is()) { - JsonObject next = nav["nextClimb"]; - nextClimb = QueueNavigationItem(next["name"] | "", next["grade"] | "", next["gradeColor"] | ""); - } - - Display.setNavigationContext(prevClimb, nextClimb, currentIndex, totalCount); - Logger.logln("Navigation: index %d/%d, prev: %s, next: %s", currentIndex + 1, totalCount, - prevClimb.isValid ? "yes" : "no", nextClimb.isValid ? "yes" : "no"); - } else { - Display.clearNavigationContext(); - } - - // Update display with gradeColor - Display.showClimb(climbName, climbGrade, currentGradeColor.c_str(), angle, climbUuid, boardType.c_str()); - -#if !BLE_PROXY_DISABLED - // Forward to BLE board - forwardToBoard(); -#endif -} - -// ============================================ -// BLE Callbacks -// ============================================ - -#if !BLE_PROXY_DISABLED -void onBleConnect(bool connected, const char* deviceName) { - bleConnected = connected; - Display.setBleStatus(bleProxyEnabled, connected); - - if (connected) { - Logger.logln("BLE: Connected to board: %s", deviceName ? deviceName : "(unknown)"); - - String address = BLEClient.getConnectedDeviceAddress(); - if (address.length() > 0) { - Config.setString("ble_board_address", address.c_str()); - } - - if (hasCurrentClimb && currentLedCommands.size() > 0) { - Logger.logln("BLE: Sending current climb to newly connected board"); - forwardToBoard(); - } - } else { - Logger.logln("BLE: Disconnected from board"); - } -} - -void onBleScan(const char* deviceName, const char* address) { - Logger.logln("BLE: Found board: %s (%s)", deviceName, address); -} - -void forwardToBoard() { - if (!bleProxyEnabled || !bleConnected || currentLedCommands.empty()) { - return; - } - - Logger.logln("BLE: Forwarding %d LED commands to board", currentLedCommands.size()); - if (BLEClient.sendLedCommands(currentLedCommands.data(), currentLedCommands.size())) { - Logger.logln("BLE: LED commands sent successfully"); - } else { - Logger.logln("BLE: Failed to send LED commands"); - } -} -#endif - -// ============================================ -// Queue Navigation Functions (with optimistic updates) -// ============================================ - -void sendNavigationMutation(const char* queueItemUuid) { - String sessionId = Config.getString("session_id"); - if (sessionId.length() == 0) { - Logger.logln("Navigation: No session ID configured"); - return; - } - - // Use queueItemUuid for direct navigation (most reliable) - String vars = "{\"sessionId\":\"" + sessionId + "\",\"direction\":\"next\""; // direction is fallback - vars += ",\"queueItemUuid\":\"" + String(queueItemUuid) + "\""; - vars += "}"; - - GraphQL.sendMutation("nav-direct", - "mutation NavDirect($sessionId: ID!, $direction: String!, $queueItemUuid: String) { " - "navigateQueue(sessionId: $sessionId, direction: $direction, queueItemUuid: $queueItemUuid) { " - "uuid climb { name difficulty } } }", - vars.c_str()); - - Logger.logln("Navigation: Sent navigate request to queueItemUuid: %s", queueItemUuid); -} - -void navigatePrevious() { - if (!backendConnected) { - Logger.logln("Navigation: Cannot navigate - not connected to backend"); - return; - } - - // Check if we have local queue state - if (Display.getQueueCount() == 0) { - Logger.logln("Navigation: No queue state - cannot navigate"); - return; - } - - if (!Display.canNavigatePrevious()) { - Logger.logln("Navigation: Already at start of queue"); - return; - } - - // Optimistic update - immediately show previous climb - if (Display.navigateToPrevious()) { - const LocalQueueItem* newCurrent = Display.getCurrentQueueItem(); - if (newCurrent) { - Logger.logln("Navigation: Optimistic update to previous - %s (uuid: %s)", newCurrent->name, newCurrent->uuid); - - // Update display immediately (optimistic) - Display.showClimb(newCurrent->name, newCurrent->grade, "", 0, newCurrent->climbUuid, boardType.c_str()); - - // Send mutation with queueItemUuid for backend sync - sendNavigationMutation(newCurrent->uuid); - } - } -} - -void navigateNext() { - if (!backendConnected) { - Logger.logln("Navigation: Cannot navigate - not connected to backend"); - return; - } - - // Check if we have local queue state - if (Display.getQueueCount() == 0) { - Logger.logln("Navigation: No queue state - cannot navigate"); - return; - } - - if (!Display.canNavigateNext()) { - Logger.logln("Navigation: Already at end of queue"); - return; - } - - // Optimistic update - immediately show next climb - if (Display.navigateToNext()) { - const LocalQueueItem* newCurrent = Display.getCurrentQueueItem(); - if (newCurrent) { - Logger.logln("Navigation: Optimistic update to next - %s (uuid: %s)", newCurrent->name, newCurrent->uuid); - - // Update display immediately (optimistic) - Display.showClimb(newCurrent->name, newCurrent->grade, "", 0, newCurrent->climbUuid, boardType.c_str()); - - // Send mutation with queueItemUuid for backend sync - sendNavigationMutation(newCurrent->uuid); - } - } -} diff --git a/package-lock.json b/package-lock.json index cff1603d..411d654a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4589,7 +4589,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.57.0" @@ -8823,7 +8823,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -12263,7 +12263,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.57.0" @@ -12282,7 +12282,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -12605,6 +12605,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12624,6 +12625,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -13141,6 +13143,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { @@ -14036,19 +14039,6 @@ "node": ">=20" } }, - "node_modules/tree-sitter": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", - "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - } - }, "node_modules/tree-sitter-json": { "version": "0.24.8", "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz", diff --git a/packages/backend/src/graphql/resolvers/controller/grade-colors.ts b/packages/backend/src/graphql/resolvers/controller/grade-colors.ts new file mode 100644 index 00000000..fffcfb35 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/controller/grade-colors.ts @@ -0,0 +1,110 @@ +/** + * V-grade color scheme based on thecrag.com grade coloring + * Ported from packages/web/app/lib/grade-colors.ts for backend use + * + * Color progression from yellow (easy) to purple (hard): + * - V0: Yellow + * - V1-V2: Orange + * - V3-V4: Dark orange/red-orange + * - V5-V6: Red + * - V7-V10: Dark red/crimson + * - V11+: Purple/magenta + */ + +// V-grade to hex color mapping +const V_GRADE_COLORS: Record = { + V0: '#FFEB3B', // Yellow + V1: '#FFC107', // Amber/Yellow-orange + V2: '#FF9800', // Orange + V3: '#FF7043', // Deep orange + V4: '#FF5722', // Red-orange + V5: '#F44336', // Red + V6: '#E53935', // Red (slightly darker) + V7: '#D32F2F', // Dark red + V8: '#C62828', // Darker red + V9: '#B71C1C', // Deep red + V10: '#A11B4A', // Red-purple transition + V11: '#9C27B0', // Purple + V12: '#7B1FA2', // Dark purple + V13: '#6A1B9A', // Darker purple + V14: '#5C1A87', // Deep purple + V15: '#4A148C', // Very deep purple + V16: '#38006B', // Near black purple + V17: '#2A0054', // Darkest purple +}; + +// Font grade to hex color mapping (uses same color as corresponding V-grade) +const FONT_GRADE_COLORS: Record = { + '4a': '#FFEB3B', // V0 + '4b': '#FFEB3B', // V0 + '4c': '#FFEB3B', // V0 + '5a': '#FFC107', // V1 + '5b': '#FFC107', // V1 + '5c': '#FF9800', // V2 + '6a': '#FF7043', // V3 + '6a+': '#FF7043', // V3 + '6b': '#FF5722', // V4 + '6b+': '#FF5722', // V4 + '6c': '#F44336', // V5 + '6c+': '#F44336', // V5 + '7a': '#E53935', // V6 + '7a+': '#D32F2F', // V7 + '7b': '#C62828', // V8 + '7b+': '#C62828', // V8 + '7c': '#B71C1C', // V9 + '7c+': '#A11B4A', // V10 + '8a': '#9C27B0', // V11 + '8a+': '#7B1FA2', // V12 + '8b': '#6A1B9A', // V13 + '8b+': '#5C1A87', // V14 + '8c': '#4A148C', // V15 + '8c+': '#38006B', // V16 +}; + +// Default color when grade cannot be determined +const DEFAULT_GRADE_COLOR = '#808080'; // Gray + +/** + * Get color for a V-grade string (e.g., "V3", "V10") + * @param vGrade - V-grade string like "V3" or "V10" + * @returns Hex color string, or default color if not found + */ +export function getVGradeColor(vGrade: string | null | undefined): string { + if (!vGrade) return DEFAULT_GRADE_COLOR; + const normalized = vGrade.toUpperCase(); + return V_GRADE_COLORS[normalized] ?? DEFAULT_GRADE_COLOR; +} + +/** + * Get color for a Font grade string (e.g., "6a", "7b+") + * @param fontGrade - Font grade string like "6a" or "7b+" + * @returns Hex color string, or default color if not found + */ +export function getFontGradeColor(fontGrade: string | null | undefined): string { + if (!fontGrade) return DEFAULT_GRADE_COLOR; + return FONT_GRADE_COLORS[fontGrade.toLowerCase()] ?? DEFAULT_GRADE_COLOR; +} + +/** + * Get color for a difficulty string that may contain both Font and V-grade (e.g., "6a/V3") + * Extracts the V-grade and returns its color + * @param difficulty - Difficulty string like "6a/V3" or "V5" + * @returns Hex color string, always returns a color (defaults to gray) + */ +export function getGradeColor(difficulty: string | null | undefined): string { + if (!difficulty) return DEFAULT_GRADE_COLOR; + + // Try to extract V-grade first + const vGradeMatch = difficulty.match(/V\d+/i); + if (vGradeMatch) { + return getVGradeColor(vGradeMatch[0]); + } + + // Fall back to Font grade + const fontGradeMatch = difficulty.match(/\d[abc]\+?/i); + if (fontGradeMatch) { + return getFontGradeColor(fontGradeMatch[0]); + } + + return DEFAULT_GRADE_COLOR; +} diff --git a/packages/backend/src/graphql/resolvers/controller/navigation-helpers.ts b/packages/backend/src/graphql/resolvers/controller/navigation-helpers.ts new file mode 100644 index 00000000..408cdc2b --- /dev/null +++ b/packages/backend/src/graphql/resolvers/controller/navigation-helpers.ts @@ -0,0 +1,62 @@ +import type { ClimbQueueItem, QueueNavigationItem, QueueNavigationContext } from '@boardsesh/shared-schema'; +import { getGradeColor } from './grade-colors'; + +/** + * Build a minimal navigation item from a queue item for ESP32 display + * Contains only the essential info needed to show prev/next climb indicators + */ +export function buildNavigationItem(item: ClimbQueueItem): QueueNavigationItem { + const climb = item.climb; + return { + name: climb.name, + grade: climb.difficulty, + gradeColor: getGradeColor(climb.difficulty), + }; +} + +/** + * Build navigation context for the current climb in the queue + * Returns prev/next climbs and position info for ESP32 navigation UI + * + * @param queue - Full queue array + * @param currentIndex - Index of the current climb in the queue (-1 if not in queue) + * @returns Navigation context with up to 3 previous climbs and 1 next climb + */ +export function buildNavigationContext( + queue: ClimbQueueItem[], + currentIndex: number +): QueueNavigationContext { + const previousClimbs: QueueNavigationItem[] = []; + let nextClimb: QueueNavigationItem | null = null; + + // Get up to 3 previous climbs (most recent first) + if (currentIndex > 0) { + const startIdx = Math.max(0, currentIndex - 3); + for (let i = currentIndex - 1; i >= startIdx; i--) { + previousClimbs.push(buildNavigationItem(queue[i])); + } + } + + // Get next climb if available + if (currentIndex >= 0 && currentIndex < queue.length - 1) { + nextClimb = buildNavigationItem(queue[currentIndex + 1]); + } + + return { + previousClimbs, + nextClimb, + currentIndex: Math.max(0, currentIndex), + totalCount: queue.length, + }; +} + +/** + * Find the index of a climb in the queue by UUID + * @param queue - Full queue array + * @param climbUuid - UUID of the climb to find + * @returns Index of the climb, or -1 if not found + */ +export function findClimbIndex(queue: ClimbQueueItem[], climbUuid: string | undefined): number { + if (!climbUuid) return -1; + return queue.findIndex((item) => item.uuid === climbUuid); +} From 56cf8216300f3a529e8418f28662e459a596e634 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 5 Feb 2026 12:00:10 +1100 Subject: [PATCH 07/14] fix: Address review feedback - BLE race condition and add tests - Add atomic flag to prevent BLE proxy scan/connect race condition between handleBoardFound and handleScanComplete callbacks - Document hardcoded BLE delays (100ms, 200ms, 500ms) with explanations - Add grade-colors.test.ts (32 tests for grade color utilities) - Add navigation-helpers.test.ts (17 tests for navigation helpers) - Add navigate-queue.test.ts (15 tests for navigateQueue mutation) - Update test setup to include esp32_controllers table - Fix controller.test.ts to create test users for FK constraints Co-Authored-By: Claude Opus 4.5 --- embedded/libs/ble-proxy/src/ble_proxy.cpp | 31 +- embedded/libs/ble-proxy/src/ble_proxy.h | 6 + embedded/libs/ble-proxy/src/ble_scanner.cpp | 5 +- .../backend/src/__tests__/controller.test.ts | 36 ++ .../src/__tests__/grade-colors.test.ts | 152 +++++ .../src/__tests__/navigate-queue.test.ts | 601 ++++++++++++++++++ .../src/__tests__/navigation-helpers.test.ts | 248 ++++++++ packages/backend/src/__tests__/setup.ts | 23 + 8 files changed, 1096 insertions(+), 6 deletions(-) create mode 100644 packages/backend/src/__tests__/grade-colors.test.ts create mode 100644 packages/backend/src/__tests__/navigate-queue.test.ts create mode 100644 packages/backend/src/__tests__/navigation-helpers.test.ts diff --git a/embedded/libs/ble-proxy/src/ble_proxy.cpp b/embedded/libs/ble-proxy/src/ble_proxy.cpp index 470a1b27..854a0e5a 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.cpp +++ b/embedded/libs/ble-proxy/src/ble_proxy.cpp @@ -121,7 +121,9 @@ void BLEProxy::loop() { Logger.logln("BLEProxy: Addr: %s", connectAddr.toString().c_str()); - // Brief delay for scan cleanup + // 100ms delay: Allow NimBLE to fully release scan resources before + // initiating client connection. Without this, connection may fail + // due to scan cleanup not completing. delay(100); // Now connect @@ -214,7 +216,10 @@ void BLEProxy::startScan() { void BLEProxy::handleBoardFound(const DiscoveredBoard& board) { // If we're still scanning and haven't found a board yet, mark it for connection // Don't stop scan or connect from callback - do it in loop() to avoid re-entry - if (state == BLEProxyState::SCANNING && pendingConnectName.length() == 0) { + // Atomically check and set connectionInitiated to prevent race with handleScanComplete + if (state == BLEProxyState::SCANNING && + pendingConnectName.length() == 0 && + !connectionInitiated.exchange(true)) { Logger.logln("BLEProxy: Found %s", board.name.c_str()); // Store target info - loop() will handle connection @@ -254,11 +259,19 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { } if (target) { + // Atomically check and set to prevent race with handleBoardFound + if (connectionInitiated.exchange(true)) { + Logger.logln("BLEProxy: Connection already initiated by handleBoardFound, skipping"); + return; + } + Logger.logln("BLEProxy: Will connect to %s (%s)", target->name.c_str(), target->address.toString().c_str()); - // Small delay to allow NimBLE to fully release scan resources - // before initiating client connection setState(BLEProxyState::CONNECTING); + + // 100ms delay: Allow NimBLE to fully release scan resources before + // initiating client connection. Without this, connection may fail + // due to scan cleanup not completing. delay(100); Logger.logln("BLEProxy: Initiating connection..."); @@ -275,7 +288,11 @@ void BLEProxy::handleBoardConnected(bool connected) { // Now that we're connected to the real board, start advertising // so phone apps can connect to us - delay(200); // Brief delay after client connection stabilizes + + // 200ms delay: Allow BLE client connection to stabilize before + // starting server advertising. This prevents GATT server issues + // when client and server start simultaneously. + delay(200); Logger.logln("BLEProxy: Starting BLE advertising"); // Use BLE.startAdvertising() which properly manages advertising state @@ -283,6 +300,10 @@ void BLEProxy::handleBoardConnected(bool connected) { BLE.startAdvertising(); } else { Logger.logln("BLEProxy: Board disconnected"); + + // Reset connection flag so next scan can initiate a new connection + connectionInitiated = false; + setState(BLEProxyState::RECONNECTING); } } diff --git a/embedded/libs/ble-proxy/src/ble_proxy.h b/embedded/libs/ble-proxy/src/ble_proxy.h index 44f86dbd..a890faf1 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.h +++ b/embedded/libs/ble-proxy/src/ble_proxy.h @@ -5,6 +5,7 @@ #include "ble_scanner.h" #include +#include // Proxy state machine enum class BLEProxyState { @@ -134,6 +135,11 @@ class BLEProxy { ProxyDataCallback dataCallback; ProxySendToAppCallback sendToAppCallback; + // Atomic flag to prevent race between handleBoardFound/handleScanComplete + // callbacks and loop(). Both callbacks can fire asynchronously from NimBLE + // and may attempt to initiate a connection simultaneously. + std::atomic connectionInitiated{false}; + void setState(BLEProxyState newState); void startScan(); }; diff --git a/embedded/libs/ble-proxy/src/ble_scanner.cpp b/embedded/libs/ble-proxy/src/ble_scanner.cpp index f5ce5151..cfeb6f16 100644 --- a/embedded/libs/ble-proxy/src/ble_scanner.cpp +++ b/embedded/libs/ble-proxy/src/ble_scanner.cpp @@ -127,7 +127,10 @@ void BLEScanner::scanCompleteCB(NimBLEScanResults results) { } instance->scanning = false; - // Wait for BLE stack to settle after scan stops + // 500ms delay: Wait for BLE stack to settle after scan stops. + // NimBLE requires scan to be fully stopped before creating + // client connections. This delay ensures scan resources are + // completely released. delay(500); Logger.logln("BLEScanner: Scan done, %d boards", instance->discoveredBoards.size()); diff --git a/packages/backend/src/__tests__/controller.test.ts b/packages/backend/src/__tests__/controller.test.ts index 8d0ad76e..6852dd2f 100644 --- a/packages/backend/src/__tests__/controller.test.ts +++ b/packages/backend/src/__tests__/controller.test.ts @@ -41,6 +41,12 @@ function createControllerContext( describe('Controller Mutations', () => { beforeEach(async () => { + // Create test user if not exists (needed for FK constraint) + await db.execute(sql` + INSERT INTO users (id, email, name, created_at, updated_at) + VALUES (${TEST_USER_ID}, 'test@controller.test', 'Test User', now(), now()) + ON CONFLICT (id) DO NOTHING + `); // Clean up test controllers await db.execute(sql`DELETE FROM esp32_controllers WHERE user_id = ${TEST_USER_ID}`); }); @@ -141,6 +147,18 @@ describe('Controller Mutations', () => { }); it('should not delete a controller owned by another user', async () => { + // Create test users for this test + await db.execute(sql` + INSERT INTO users (id, email, name, created_at, updated_at) + VALUES ('user-1', 'user1@test.com', 'User 1', now(), now()) + ON CONFLICT (id) DO NOTHING + `); + await db.execute(sql` + INSERT INTO users (id, email, name, created_at, updated_at) + VALUES ('user-2', 'user2@test.com', 'User 2', now(), now()) + ON CONFLICT (id) DO NOTHING + `); + const ctx1 = createMockContext({ userId: 'user-1' }); const ctx2 = createMockContext({ userId: 'user-2' }); @@ -215,6 +233,18 @@ describe('Controller Mutations', () => { }); it('should reject authorization from non-owner', async () => { + // Create test users for this test + await db.execute(sql` + INSERT INTO users (id, email, name, created_at, updated_at) + VALUES ('user-1', 'user1@test.com', 'User 1', now(), now()) + ON CONFLICT (id) DO NOTHING + `); + await db.execute(sql` + INSERT INTO users (id, email, name, created_at, updated_at) + VALUES ('user-2', 'user2@test.com', 'User 2', now(), now()) + ON CONFLICT (id) DO NOTHING + `); + const ctx1 = createMockContext({ userId: 'user-1' }); const ctx2 = createMockContext({ userId: 'user-2' }); @@ -303,6 +333,12 @@ describe('Controller Mutations', () => { describe('Controller Queries', () => { beforeEach(async () => { + // Create test user if not exists (needed for FK constraint) + await db.execute(sql` + INSERT INTO users (id, email, name, created_at, updated_at) + VALUES (${TEST_USER_ID}, 'test@controller.test', 'Test User', now(), now()) + ON CONFLICT (id) DO NOTHING + `); // Clean up test controllers await db.execute(sql`DELETE FROM esp32_controllers WHERE user_id = ${TEST_USER_ID}`); }); diff --git a/packages/backend/src/__tests__/grade-colors.test.ts b/packages/backend/src/__tests__/grade-colors.test.ts new file mode 100644 index 00000000..1a3b5e6f --- /dev/null +++ b/packages/backend/src/__tests__/grade-colors.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { + getVGradeColor, + getFontGradeColor, + getGradeColor, +} from '../graphql/resolvers/controller/grade-colors'; + +const DEFAULT_GRADE_COLOR = '#808080'; + +describe('Grade Color Utilities', () => { + describe('getVGradeColor', () => { + it('should return correct color for V0', () => { + expect(getVGradeColor('V0')).toBe('#FFEB3B'); + }); + + it('should return correct color for V5', () => { + expect(getVGradeColor('V5')).toBe('#F44336'); + }); + + it('should return correct color for V10', () => { + expect(getVGradeColor('V10')).toBe('#A11B4A'); + }); + + it('should return correct color for V17', () => { + expect(getVGradeColor('V17')).toBe('#2A0054'); + }); + + it('should be case insensitive', () => { + expect(getVGradeColor('v3')).toBe('#FF7043'); + expect(getVGradeColor('v10')).toBe('#A11B4A'); + }); + + it('should return default color for invalid V-grade', () => { + expect(getVGradeColor('V18')).toBe(DEFAULT_GRADE_COLOR); + expect(getVGradeColor('V-1')).toBe(DEFAULT_GRADE_COLOR); + expect(getVGradeColor('invalid')).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for null', () => { + expect(getVGradeColor(null)).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for undefined', () => { + expect(getVGradeColor(undefined)).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for empty string', () => { + expect(getVGradeColor('')).toBe(DEFAULT_GRADE_COLOR); + }); + }); + + describe('getFontGradeColor', () => { + it('should return correct color for 4a (V0 equivalent)', () => { + expect(getFontGradeColor('4a')).toBe('#FFEB3B'); + }); + + it('should return correct color for 6a', () => { + expect(getFontGradeColor('6a')).toBe('#FF7043'); + }); + + it('should return correct color for 6a+', () => { + expect(getFontGradeColor('6a+')).toBe('#FF7043'); + }); + + it('should return correct color for 7b+', () => { + expect(getFontGradeColor('7b+')).toBe('#C62828'); + }); + + it('should return correct color for 8c+', () => { + expect(getFontGradeColor('8c+')).toBe('#38006B'); + }); + + it('should be case insensitive', () => { + expect(getFontGradeColor('6A')).toBe('#FF7043'); + expect(getFontGradeColor('7B+')).toBe('#C62828'); + }); + + it('should return default color for invalid Font grade', () => { + expect(getFontGradeColor('9a')).toBe(DEFAULT_GRADE_COLOR); + expect(getFontGradeColor('invalid')).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for null', () => { + expect(getFontGradeColor(null)).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for undefined', () => { + expect(getFontGradeColor(undefined)).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for empty string', () => { + expect(getFontGradeColor('')).toBe(DEFAULT_GRADE_COLOR); + }); + }); + + describe('getGradeColor', () => { + it('should extract V-grade from mixed format "6a/V3"', () => { + expect(getGradeColor('6a/V3')).toBe('#FF7043'); // V3 color + }); + + it('should extract V-grade from mixed format "7b/V8"', () => { + expect(getGradeColor('7b/V8')).toBe('#C62828'); // V8 color + }); + + it('should prefer V-grade over Font grade', () => { + // If both are present, V-grade takes precedence + expect(getGradeColor('6a/V5')).toBe('#F44336'); // V5 color, not 6a color + }); + + it('should handle standalone V-grade', () => { + expect(getGradeColor('V4')).toBe('#FF5722'); + }); + + it('should fall back to Font grade when no V-grade present', () => { + expect(getGradeColor('6a')).toBe('#FF7043'); + expect(getGradeColor('7a+')).toBe('#D32F2F'); + }); + + it('should be case insensitive for V-grade extraction', () => { + expect(getGradeColor('6a/v3')).toBe('#FF7043'); + }); + + it('should be case insensitive for Font grade extraction', () => { + expect(getGradeColor('6A')).toBe('#FF7043'); + }); + + it('should return default color for invalid difficulty string', () => { + expect(getGradeColor('invalid')).toBe(DEFAULT_GRADE_COLOR); + expect(getGradeColor('easy')).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for null', () => { + expect(getGradeColor(null)).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for undefined', () => { + expect(getGradeColor(undefined)).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should return default color for empty string', () => { + expect(getGradeColor('')).toBe(DEFAULT_GRADE_COLOR); + }); + + it('should handle V-grade embedded in longer strings', () => { + expect(getGradeColor('Grade: V7 (Hard)')).toBe('#D32F2F'); + }); + + it('should handle Font grade embedded in longer strings', () => { + expect(getGradeColor('Grade: 7a (Hard)')).toBe('#E53935'); + }); + }); +}); diff --git a/packages/backend/src/__tests__/navigate-queue.test.ts b/packages/backend/src/__tests__/navigate-queue.test.ts new file mode 100644 index 00000000..5f0a4b72 --- /dev/null +++ b/packages/backend/src/__tests__/navigate-queue.test.ts @@ -0,0 +1,601 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import { roomManager } from '../services/room-manager'; +import { db } from '../db/client'; +import { esp32Controllers } from '@boardsesh/db/schema/app'; +import { eq, sql } from 'drizzle-orm'; +import { controllerMutations } from '../graphql/resolvers/controller/mutations'; +import type { ConnectionContext, ClimbQueueItem, Climb } from '@boardsesh/shared-schema'; +import { pubsub } from '../pubsub/index'; + +// Test IDs +const TEST_USER_ID = 'test-user-navigate-queue'; +const TEST_SESSION_ID = 'test-session-navigate-queue'; + +// Helper to create a mock climb +function createMockClimb(overrides: Partial = {}): Climb { + return { + uuid: uuidv4(), + name: 'Test Climb', + difficulty: '6a/V3', + angle: 40, + ascensionistCount: 10, + qualityAverage: 3.5, + difficultyAverage: 3.0, + description: 'A test climb', + setter_username: 'test_setter', + frames: 'test-frames', + ...overrides, + }; +} + +// Helper to create a mock queue item +function createMockQueueItem(overrides: Partial = {}): ClimbQueueItem { + return { + uuid: uuidv4(), + climb: createMockClimb(), + suggested: false, + ...overrides, + }; +} + +// Helper to create mock authenticated user context +function createMockContext(overrides: Partial = {}): ConnectionContext { + return { + connectionId: `conn-${Date.now()}`, + isAuthenticated: true, + userId: TEST_USER_ID, + sessionId: TEST_SESSION_ID, + rateLimitTokens: 60, + rateLimitLastReset: Date.now(), + ...overrides, + }; +} + +// Helper to create controller context +function createControllerContext( + controllerId: string, + controllerApiKey: string, + overrides: Partial = {} +): ConnectionContext { + return { + connectionId: `conn-${Date.now()}`, + isAuthenticated: false, + userId: undefined, + sessionId: undefined, + controllerId, + controllerApiKey, + rateLimitTokens: 60, + rateLimitLastReset: Date.now(), + ...overrides, + }; +} + +describe('navigateQueue mutation', () => { + let publishSpy: ReturnType; + let getQueueStateSpy: ReturnType; + let updateQueueStateSpy: ReturnType; + let registeredController: { controllerId: string; apiKey: string }; + + beforeEach(async () => { + // Reset room manager + roomManager.reset(); + + // Create test user if not exists (needed for FK constraint) + await db.execute(sql` + INSERT INTO users (id, email, name, created_at, updated_at) + VALUES (${TEST_USER_ID}, 'test@navigate-queue.test', 'Test User', now(), now()) + ON CONFLICT (id) DO NOTHING + `); + + // Clean up test data + await db.execute(sql`DELETE FROM esp32_controllers WHERE user_id = ${TEST_USER_ID}`); + + // Register a controller for testing + const userCtx = createMockContext(); + const result = await controllerMutations.registerController( + undefined, + { + input: { + boardName: 'kilter', + layoutId: 1, + sizeId: 10, + setIds: '1,2,3', + name: 'Test Controller', + }, + }, + userCtx + ); + registeredController = { controllerId: result.controllerId, apiKey: result.apiKey }; + + // Authorize controller for test session + await controllerMutations.authorizeControllerForSession( + undefined, + { controllerId: result.controllerId, sessionId: TEST_SESSION_ID }, + userCtx + ); + + // Spy on pubsub.publishQueueEvent + publishSpy = vi.spyOn(pubsub, 'publishQueueEvent').mockImplementation(() => {}); + + // Spy on roomManager methods - these will be configured per test + getQueueStateSpy = vi.spyOn(roomManager, 'getQueueState'); + updateQueueStateSpy = vi.spyOn(roomManager, 'updateQueueState'); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + await db.execute(sql`DELETE FROM esp32_controllers WHERE user_id = ${TEST_USER_ID}`); + }); + + describe('Direct navigation via queueItemUuid', () => { + it('should navigate directly to a valid queueItemUuid', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const item3 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 3' }) }); + const queue = [item1, item2, item3]; + + // Mock roomManager methods + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 2, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + // Navigate directly to item3 + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next', queueItemUuid: item3.uuid }, + controllerCtx + ); + + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item3.uuid); + expect(result!.climb.name).toBe('Climb 3'); + }); + + it('should fall back to direction-based navigation when queueItemUuid not found', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 2, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + // Navigate with non-existent queueItemUuid - should fall back to direction + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next', queueItemUuid: 'non-existent-uuid' }, + controllerCtx + ); + + // Should navigate to next item (item2) + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item2.uuid); + }); + }); + + describe('Direction-based navigation', () => { + it('should navigate "next" from middle of queue', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const item3 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 3' }) }); + const queue = [item1, item2, item3]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item2, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 2, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item3.uuid); + }); + + it('should stay at end when navigating "next" at end of queue', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item2, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + + // Should stay at item2 (already at end) - updateQueueState should NOT be called + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item2.uuid); + expect(updateQueueStateSpy).not.toHaveBeenCalled(); + }); + + it('should navigate "previous" from middle of queue', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const item3 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 3' }) }); + const queue = [item1, item2, item3]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item2, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 2, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'previous' }, + controllerCtx + ); + + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item1.uuid); + }); + + it('should stay at start when navigating "previous" at start of queue', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'previous' }, + controllerCtx + ); + + // Should stay at item1 (already at start) - updateQueueState should NOT be called + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item1.uuid); + expect(updateQueueStateSpy).not.toHaveBeenCalled(); + }); + + it('should start at beginning when navigating "next" with no current climb', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: null, // No current climb + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 2, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + + // Should start at first item + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item1.uuid); + }); + + it('should start at end when navigating "previous" with no current climb', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: null, // No current climb + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 2, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'previous' }, + controllerCtx + ); + + // Should start at last item + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item2.uuid); + }); + }); + + describe('Edge cases', () => { + it('should return null for empty queue', async () => { + getQueueStateSpy.mockResolvedValue({ + queue: [], + currentClimbQueueItem: null, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + + expect(result).toBeNull(); + }); + + it('should handle single-item queue', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Only Climb' }) }); + const queue = [item1]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + // Navigate next - should stay at same position + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item1.uuid); + expect(updateQueueStateSpy).not.toHaveBeenCalled(); + }); + + it('should throw error for invalid direction', async () => { + const item1 = createMockQueueItem(); + const queue = [item1]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + await expect( + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'invalid' }, + controllerCtx + ) + ).rejects.toThrow('Invalid direction'); + }); + }); + + describe('Event publishing', () => { + it('should publish CurrentClimbChanged event with clientId=null', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 2, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + + // Verify event was published with correct structure + expect(publishSpy).toHaveBeenCalledWith( + TEST_SESSION_ID, + expect.objectContaining({ + __typename: 'CurrentClimbChanged', + clientId: null, // Should be null for navigation events + item: expect.objectContaining({ uuid: item2.uuid }), + sequence: 2, + }) + ); + }); + + it('should include correct sequence number in published event', async () => { + const item1 = createMockQueueItem(); + const item2 = createMockQueueItem(); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 5, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 6, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + + expect(publishSpy).toHaveBeenCalledWith( + TEST_SESSION_ID, + expect.objectContaining({ + sequence: 6, + }) + ); + }); + }); + + describe('Authorization', () => { + it('should require controller authentication', async () => { + // User context without controller auth + const userCtx = createMockContext(); + + await expect( + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + userCtx + ) + ).rejects.toThrow('Controller authentication required'); + }); + + it('should allow any valid controller to navigate any session', async () => { + // Create test user for a second controller + await db.execute(sql` + INSERT INTO users (id, email, name, created_at, updated_at) + VALUES ('test-user-2', 'test2@navigate-queue.test', 'Test User 2', now(), now()) + ON CONFLICT (id) DO NOTHING + `); + + // Register a second controller (no explicit session authorization needed) + const userCtx = createMockContext({ userId: 'test-user-2' }); + const secondController = await controllerMutations.registerController( + undefined, + { + input: { + boardName: 'kilter', + layoutId: 1, + sizeId: 10, + setIds: '1,2,3', + name: 'Second Controller', + }, + }, + userCtx + ); + + const item1 = createMockQueueItem(); + const queue = [item1]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + + const controllerCtx = createControllerContext( + secondController.controllerId, + secondController.apiKey + ); + + // Controller should be able to navigate any session (authorization is just API key) + const result = await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + + // Should succeed - returns the climb at current position + expect(result).not.toBeNull(); + expect(result!.uuid).toBe(item1.uuid); + + // Cleanup + await db.execute(sql`DELETE FROM esp32_controllers WHERE user_id = 'test-user-2'`); + }); + }); +}); diff --git a/packages/backend/src/__tests__/navigation-helpers.test.ts b/packages/backend/src/__tests__/navigation-helpers.test.ts new file mode 100644 index 00000000..df745631 --- /dev/null +++ b/packages/backend/src/__tests__/navigation-helpers.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { + buildNavigationItem, + buildNavigationContext, + findClimbIndex, +} from '../graphql/resolvers/controller/navigation-helpers'; +import type { ClimbQueueItem, Climb } from '@boardsesh/shared-schema'; + +// Helper to create a mock climb +function createMockClimb(overrides: Partial = {}): Climb { + return { + uuid: 'climb-uuid-1', + name: 'Test Climb', + difficulty: '6a/V3', + angle: 40, + ascensionistCount: 10, + qualityAverage: 3.5, + difficultyAverage: 3.0, + description: 'A test climb', + setter_username: 'test_setter', + frames: 'test-frames', + ...overrides, + }; +} + +// Helper to create a mock queue item +function createMockQueueItem(overrides: Partial = {}): ClimbQueueItem { + return { + uuid: `queue-item-${Date.now()}-${Math.random()}`, + climb: createMockClimb(), + suggested: false, + ...overrides, + }; +} + +describe('Navigation Helper Utilities', () => { + describe('buildNavigationItem', () => { + it('should build navigation item with name, grade, and gradeColor', () => { + const queueItem = createMockQueueItem({ + climb: createMockClimb({ + name: 'Boulder Problem', + difficulty: 'V5', + }), + }); + + const result = buildNavigationItem(queueItem); + + expect(result.name).toBe('Boulder Problem'); + expect(result.grade).toBe('V5'); + expect(result.gradeColor).toBe('#F44336'); // V5 color + }); + + it('should use getGradeColor for mixed grade formats', () => { + const queueItem = createMockQueueItem({ + climb: createMockClimb({ + difficulty: '7a/V6', + }), + }); + + const result = buildNavigationItem(queueItem); + + expect(result.gradeColor).toBe('#E53935'); // V6 color + }); + + it('should return default gray color for null difficulty', () => { + const queueItem = createMockQueueItem({ + climb: createMockClimb({ + difficulty: null as unknown as string, + }), + }); + + const result = buildNavigationItem(queueItem); + + expect(result.gradeColor).toBe('#808080'); // Default gray + }); + }); + + describe('buildNavigationContext', () => { + it('should return empty context for empty queue', () => { + const result = buildNavigationContext([], -1); + + expect(result.previousClimbs).toEqual([]); + expect(result.nextClimb).toBeNull(); + expect(result.currentIndex).toBe(0); + expect(result.totalCount).toBe(0); + }); + + it('should return no previous climbs when at start of queue', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1' }), + createMockQueueItem({ uuid: 'item-2' }), + createMockQueueItem({ uuid: 'item-3' }), + ]; + + const result = buildNavigationContext(queue, 0); + + expect(result.previousClimbs).toHaveLength(0); + expect(result.nextClimb).not.toBeNull(); + expect(result.currentIndex).toBe(0); + expect(result.totalCount).toBe(3); + }); + + it('should return no next climb when at end of queue', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1' }), + createMockQueueItem({ uuid: 'item-2' }), + createMockQueueItem({ uuid: 'item-3' }), + ]; + + const result = buildNavigationContext(queue, 2); + + expect(result.previousClimbs).toHaveLength(2); + expect(result.nextClimb).toBeNull(); + expect(result.currentIndex).toBe(2); + expect(result.totalCount).toBe(3); + }); + + it('should return up to 3 previous climbs (most recent first)', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1', climb: createMockClimb({ name: 'Climb 1' }) }), + createMockQueueItem({ uuid: 'item-2', climb: createMockClimb({ name: 'Climb 2' }) }), + createMockQueueItem({ uuid: 'item-3', climb: createMockClimb({ name: 'Climb 3' }) }), + createMockQueueItem({ uuid: 'item-4', climb: createMockClimb({ name: 'Climb 4' }) }), + createMockQueueItem({ uuid: 'item-5', climb: createMockClimb({ name: 'Climb 5' }) }), + ]; + + const result = buildNavigationContext(queue, 4); // At Climb 5 + + expect(result.previousClimbs).toHaveLength(3); + // Most recent first (Climb 4, Climb 3, Climb 2) + expect(result.previousClimbs[0].name).toBe('Climb 4'); + expect(result.previousClimbs[1].name).toBe('Climb 3'); + expect(result.previousClimbs[2].name).toBe('Climb 2'); + }); + + it('should return 1 previous climb when only 1 available', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1', climb: createMockClimb({ name: 'Climb 1' }) }), + createMockQueueItem({ uuid: 'item-2', climb: createMockClimb({ name: 'Climb 2' }) }), + ]; + + const result = buildNavigationContext(queue, 1); + + expect(result.previousClimbs).toHaveLength(1); + expect(result.previousClimbs[0].name).toBe('Climb 1'); + }); + + it('should return next climb when available', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1', climb: createMockClimb({ name: 'Climb 1' }) }), + createMockQueueItem({ uuid: 'item-2', climb: createMockClimb({ name: 'Climb 2' }) }), + createMockQueueItem({ uuid: 'item-3', climb: createMockClimb({ name: 'Climb 3' }) }), + ]; + + const result = buildNavigationContext(queue, 1); + + expect(result.nextClimb).not.toBeNull(); + expect(result.nextClimb?.name).toBe('Climb 3'); + }); + + it('should handle single-item queue', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1', climb: createMockClimb({ name: 'Only Climb' }) }), + ]; + + const result = buildNavigationContext(queue, 0); + + expect(result.previousClimbs).toHaveLength(0); + expect(result.nextClimb).toBeNull(); + expect(result.currentIndex).toBe(0); + expect(result.totalCount).toBe(1); + }); + + it('should handle middle of queue', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1', climb: createMockClimb({ name: 'Climb 1' }) }), + createMockQueueItem({ uuid: 'item-2', climb: createMockClimb({ name: 'Climb 2' }) }), + createMockQueueItem({ uuid: 'item-3', climb: createMockClimb({ name: 'Climb 3' }) }), + ]; + + const result = buildNavigationContext(queue, 1); + + expect(result.previousClimbs).toHaveLength(1); + expect(result.previousClimbs[0].name).toBe('Climb 1'); + expect(result.nextClimb?.name).toBe('Climb 3'); + expect(result.currentIndex).toBe(1); + expect(result.totalCount).toBe(3); + }); + + it('should clamp negative currentIndex to 0', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1' }), + createMockQueueItem({ uuid: 'item-2' }), + ]; + + const result = buildNavigationContext(queue, -1); + + expect(result.currentIndex).toBe(0); + }); + }); + + describe('findClimbIndex', () => { + it('should find climb by queue item UUID', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1' }), + createMockQueueItem({ uuid: 'item-2' }), + createMockQueueItem({ uuid: 'item-3' }), + ]; + + expect(findClimbIndex(queue, 'item-1')).toBe(0); + expect(findClimbIndex(queue, 'item-2')).toBe(1); + expect(findClimbIndex(queue, 'item-3')).toBe(2); + }); + + it('should return -1 for non-existent UUID', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1' }), + createMockQueueItem({ uuid: 'item-2' }), + ]; + + expect(findClimbIndex(queue, 'non-existent')).toBe(-1); + }); + + it('should return -1 for undefined UUID', () => { + const queue = [ + createMockQueueItem({ uuid: 'item-1' }), + createMockQueueItem({ uuid: 'item-2' }), + ]; + + expect(findClimbIndex(queue, undefined)).toBe(-1); + }); + + it('should return -1 for empty queue', () => { + expect(findClimbIndex([], 'some-uuid')).toBe(-1); + }); + + it('should find first matching UUID when duplicates exist', () => { + // This shouldn't happen in practice but tests the implementation + const queue = [ + createMockQueueItem({ uuid: 'duplicate' }), + createMockQueueItem({ uuid: 'duplicate' }), + createMockQueueItem({ uuid: 'unique' }), + ]; + + expect(findClimbIndex(queue, 'duplicate')).toBe(0); + }); + }); +}); diff --git a/packages/backend/src/__tests__/setup.ts b/packages/backend/src/__tests__/setup.ts index 1239fe92..f4b49ccc 100644 --- a/packages/backend/src/__tests__/setup.ts +++ b/packages/backend/src/__tests__/setup.ts @@ -75,6 +75,29 @@ const createTablesSQL = ` CREATE INDEX IF NOT EXISTS "board_sessions_status_idx" ON "board_sessions" ("status"); CREATE INDEX IF NOT EXISTS "board_sessions_last_activity_idx" ON "board_sessions" ("last_activity"); CREATE INDEX IF NOT EXISTS "board_sessions_discovery_idx" ON "board_sessions" ("discoverable", "status", "last_activity"); + + -- Drop and recreate esp32_controllers table for controller tests + DROP TABLE IF EXISTS "esp32_controllers" CASCADE; + + -- Create esp32_controllers table + CREATE TABLE IF NOT EXISTS "esp32_controllers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" text REFERENCES "users"("id") ON DELETE CASCADE, + "api_key" varchar(64) UNIQUE NOT NULL, + "name" varchar(100), + "board_name" varchar(20) NOT NULL, + "layout_id" integer NOT NULL, + "size_id" integer NOT NULL, + "set_ids" varchar(100) NOT NULL, + "authorized_session_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "last_seen_at" timestamp + ); + + -- Create indexes for esp32_controllers + CREATE INDEX IF NOT EXISTS "esp32_controllers_user_idx" ON "esp32_controllers" ("user_id"); + CREATE INDEX IF NOT EXISTS "esp32_controllers_api_key_idx" ON "esp32_controllers" ("api_key"); + CREATE INDEX IF NOT EXISTS "esp32_controllers_session_idx" ON "esp32_controllers" ("authorized_session_id"); `; beforeAll(async () => { From f70198187fb78eb87caebc0191599d96ff9cfe53 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 5 Feb 2026 14:16:52 +1100 Subject: [PATCH 08/14] fix: Convert BLE proxy blocking delays to non-blocking timers - Replace blocking delay() calls in BLE proxy with state-machine-based non-blocking timers for WAIT_BEFORE_CONNECT and WAIT_BEFORE_ADVERTISE - Remove unused BYTES_PER_LED variable from aurora_protocol.cpp - Fix native embedded test mock issues: - Add macAddress() method to MockWiFi - Add start() method to NimBLEServer mock - Fix min/max templates to return by value instead of reference This addresses review feedback about blocking delays and test failures. Co-Authored-By: Claude Opus 4.5 --- .../aurora-protocol/src/aurora_protocol.cpp | 1 - embedded/libs/ble-proxy/src/ble_proxy.cpp | 77 +++++++++++-------- embedded/libs/ble-proxy/src/ble_proxy.h | 20 +++-- embedded/libs/ble-proxy/src/ble_scanner.cpp | 8 +- embedded/test/lib/mocks/src/Arduino.h | 9 ++- embedded/test/lib/mocks/src/NimBLEDevice.h | 5 +- embedded/test/lib/mocks/src/WiFi.h | 7 +- 7 files changed, 77 insertions(+), 50 deletions(-) diff --git a/embedded/libs/aurora-protocol/src/aurora_protocol.cpp b/embedded/libs/aurora-protocol/src/aurora_protocol.cpp index 193917e8..4cc52b2d 100644 --- a/embedded/libs/aurora-protocol/src/aurora_protocol.cpp +++ b/embedded/libs/aurora-protocol/src/aurora_protocol.cpp @@ -251,7 +251,6 @@ void AuroraProtocol::encodeLedCommands(const LedCommand* commands, int count, // Max BLE packet size is ~240 bytes, with framing overhead we can fit ~75 LEDs per packet // To be safe, use 60 LEDs per packet const int LEDS_PER_PACKET = 60; - const int BYTES_PER_LED = 3; int totalPackets = (count + LEDS_PER_PACKET - 1) / LEDS_PER_PACKET; int ledIndex = 0; diff --git a/embedded/libs/ble-proxy/src/ble_proxy.cpp b/embedded/libs/ble-proxy/src/ble_proxy.cpp index 854a0e5a..55fda46f 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.cpp +++ b/embedded/libs/ble-proxy/src/ble_proxy.cpp @@ -45,6 +45,7 @@ static void onBoardFoundStatic(const DiscoveredBoard& board) { BLEProxy::BLEProxy() : state(BLEProxyState::PROXY_DISABLED), enabled(false), scanStartTime(0), reconnectDelay(5000), + waitStartTime(0), waitDuration(0), stateCallback(nullptr), dataCallback(nullptr), sendToAppCallback(nullptr) { proxyInstance = this; } @@ -106,27 +107,32 @@ void BLEProxy::loop() { case BLEProxyState::SCANNING: // Check if we found a board to connect to if (pendingConnectName.length() > 0) { - Logger.logln("BLEProxy: Connecting to %s", pendingConnectName.c_str()); - - // Set state FIRST to prevent handleScanComplete from also trying to connect - setState(BLEProxyState::CONNECTING); - - // Clear pending name to signal we've started connection - String connectName = pendingConnectName; - NimBLEAddress connectAddr = pendingConnectAddress; - pendingConnectName = ""; + Logger.logln("BLEProxy: Found board, preparing to connect to %s", pendingConnectName.c_str()); - // Stop the scan (this may trigger handleScanComplete, but state is already CONNECTING) + // Stop the scan (this may trigger handleScanComplete, but we check pendingConnectName) Scanner.stopScan(); - Logger.logln("BLEProxy: Addr: %s", connectAddr.toString().c_str()); + Logger.logln("BLEProxy: Addr: %s", pendingConnectAddress.toString().c_str()); - // 100ms delay: Allow NimBLE to fully release scan resources before + // Non-blocking wait: Allow NimBLE to fully release scan resources before // initiating client connection. Without this, connection may fail // due to scan cleanup not completing. - delay(100); + waitStartTime = millis(); + waitDuration = 100; + setState(BLEProxyState::WAIT_BEFORE_CONNECT); + } + break; + + case BLEProxyState::WAIT_BEFORE_CONNECT: + // Non-blocking wait before connecting + if (millis() - waitStartTime >= waitDuration) { + Logger.logln("BLEProxy: Connecting to %s", pendingConnectName.c_str()); - // Now connect + // Store connection info and clear pending + NimBLEAddress connectAddr = pendingConnectAddress; + pendingConnectName = ""; + + setState(BLEProxyState::CONNECTING); BoardClient.connect(connectAddr); } break; @@ -135,6 +141,17 @@ void BLEProxy::loop() { // Handled by client callbacks break; + case BLEProxyState::WAIT_BEFORE_ADVERTISE: + // Non-blocking wait before starting advertising + if (millis() - waitStartTime >= waitDuration) { + Logger.logln("BLEProxy: Starting BLE advertising"); + // Use BLE.startAdvertising() which properly manages advertising state + // and avoids duplicate GATT server start issues + BLE.startAdvertising(); + setState(BLEProxyState::CONNECTED); + } + break; + case BLEProxyState::CONNECTED: BoardClient.loop(); break; @@ -267,15 +284,16 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { Logger.logln("BLEProxy: Will connect to %s (%s)", target->name.c_str(), target->address.toString().c_str()); - setState(BLEProxyState::CONNECTING); + // Store connection info for the wait state + pendingConnectAddress = target->address; + pendingConnectName = target->name; - // 100ms delay: Allow NimBLE to fully release scan resources before + // Non-blocking wait: Allow NimBLE to fully release scan resources before // initiating client connection. Without this, connection may fail // due to scan cleanup not completing. - delay(100); - - Logger.logln("BLEProxy: Initiating connection..."); - BoardClient.connect(target->address); + waitStartTime = millis(); + waitDuration = 100; + setState(BLEProxyState::WAIT_BEFORE_CONNECT); } else { setState(BLEProxyState::IDLE); } @@ -284,20 +302,15 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { void BLEProxy::handleBoardConnected(bool connected) { if (connected) { Logger.logln("BLEProxy: Connected to board!"); - setState(BLEProxyState::CONNECTED); // Now that we're connected to the real board, start advertising - // so phone apps can connect to us - - // 200ms delay: Allow BLE client connection to stabilize before - // starting server advertising. This prevents GATT server issues - // when client and server start simultaneously. - delay(200); - Logger.logln("BLEProxy: Starting BLE advertising"); - - // Use BLE.startAdvertising() which properly manages advertising state - // and avoids duplicate GATT server start issues - BLE.startAdvertising(); + // so phone apps can connect to us. Use non-blocking wait to allow + // BLE client connection to stabilize before starting server + // advertising. This prevents GATT server issues when client and + // server start simultaneously. + waitStartTime = millis(); + waitDuration = 200; + setState(BLEProxyState::WAIT_BEFORE_ADVERTISE); } else { Logger.logln("BLEProxy: Board disconnected"); diff --git a/embedded/libs/ble-proxy/src/ble_proxy.h b/embedded/libs/ble-proxy/src/ble_proxy.h index a890faf1..33a9630b 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.h +++ b/embedded/libs/ble-proxy/src/ble_proxy.h @@ -9,13 +9,15 @@ // Proxy state machine enum class BLEProxyState { - PROXY_DISABLED, // Proxy mode not enabled - IDLE, // Waiting to scan - SCANNING, // Scanning for boards - SCAN_COMPLETE_NONE, // Scan completed but no boards found (won't auto-retry) - CONNECTING, // Connecting to board - CONNECTED, // Connected and proxying - RECONNECTING // Connection lost, will retry + PROXY_DISABLED, // Proxy mode not enabled + IDLE, // Waiting to scan + SCANNING, // Scanning for boards + SCAN_COMPLETE_NONE, // Scan completed but no boards found (won't auto-retry) + WAIT_BEFORE_CONNECT, // Non-blocking wait after scan before connecting + CONNECTING, // Connecting to board + WAIT_BEFORE_ADVERTISE, // Non-blocking wait after connect before advertising + CONNECTED, // Connected and proxying + RECONNECTING // Connection lost, will retry }; typedef void (*ProxyStateCallback)(BLEProxyState state); @@ -127,6 +129,10 @@ class BLEProxy { unsigned long scanStartTime; unsigned long reconnectDelay; + // Non-blocking timer for wait states + unsigned long waitStartTime; + unsigned long waitDuration; + // Pending connection info (stored after scan, before connect) NimBLEAddress pendingConnectAddress; String pendingConnectName; diff --git a/embedded/libs/ble-proxy/src/ble_scanner.cpp b/embedded/libs/ble-proxy/src/ble_scanner.cpp index cfeb6f16..b6a87d98 100644 --- a/embedded/libs/ble-proxy/src/ble_scanner.cpp +++ b/embedded/libs/ble-proxy/src/ble_scanner.cpp @@ -127,11 +127,9 @@ void BLEScanner::scanCompleteCB(NimBLEScanResults results) { } instance->scanning = false; - // 500ms delay: Wait for BLE stack to settle after scan stops. - // NimBLE requires scan to be fully stopped before creating - // client connections. This delay ensures scan resources are - // completely released. - delay(500); + // Note: The BLE proxy handles settling time via its WAIT_BEFORE_CONNECT + // state, using a non-blocking timer. This avoids blocking the callback + // and keeps the main loop responsive. Logger.logln("BLEScanner: Scan done, %d boards", instance->discoveredBoards.size()); diff --git a/embedded/test/lib/mocks/src/Arduino.h b/embedded/test/lib/mocks/src/Arduino.h index df88e9cc..ab7029b7 100644 --- a/embedded/test/lib/mocks/src/Arduino.h +++ b/embedded/test/lib/mocks/src/Arduino.h @@ -15,6 +15,7 @@ #include #include #include +#include // Arduino type definitions typedef uint8_t byte; @@ -22,15 +23,17 @@ typedef bool boolean; // Arduino min/max - defined as templates to avoid conflicts with std::min/max // Note: Arduino defines these as macros, but that causes issues with STL -// Use inline functions for compatibility +// Use inline functions for compatibility. Return by value to avoid dangling references. #ifndef ARDUINO_MIN_MAX_DEFINED #define ARDUINO_MIN_MAX_DEFINED -template inline auto min(T a, U b) -> decltype(a < b ? a : b) { +template +inline typename std::common_type::type min(T a, U b) { return (a < b) ? a : b; } -template inline auto max(T a, U b) -> decltype(a > b ? a : b) { +template +inline typename std::common_type::type max(T a, U b) { return (a > b) ? a : b; } diff --git a/embedded/test/lib/mocks/src/NimBLEDevice.h b/embedded/test/lib/mocks/src/NimBLEDevice.h index 8dffb881..05f589d1 100644 --- a/embedded/test/lib/mocks/src/NimBLEDevice.h +++ b/embedded/test/lib/mocks/src/NimBLEDevice.h @@ -202,10 +202,12 @@ class NimBLEAdvertising { // NimBLEServer class NimBLEServer { public: - NimBLEServer() : callbacks_(nullptr), connectedCount_(0) {} + NimBLEServer() : callbacks_(nullptr), connectedCount_(0), started_(false) {} void setCallbacks(NimBLEServerCallbacks* callbacks) { callbacks_ = callbacks; } + void start() { started_ = true; } + NimBLEService* createService(const char* uuid) { services_.push_back(new NimBLEService(uuid)); return services_.back(); @@ -253,6 +255,7 @@ class NimBLEServer { NimBLEServerCallbacks* callbacks_; std::vector services_; int connectedCount_; + bool started_; uint16_t disconnectedHandle_ = BLE_HS_CONN_HANDLE_NONE; }; diff --git a/embedded/test/lib/mocks/src/WiFi.h b/embedded/test/lib/mocks/src/WiFi.h index a43c745e..7d9ea859 100644 --- a/embedded/test/lib/mocks/src/WiFi.h +++ b/embedded/test/lib/mocks/src/WiFi.h @@ -80,7 +80,7 @@ class MockWiFi { MockWiFi() : status_(WL_DISCONNECTED), mode_(WIFI_OFF), autoReconnect_(false), rssi_(-70), localIP_(192, 168, 1, 100), - ssid_("") {} + ssid_(""), macAddress_("AA:BB:CC:DD:EE:FF") {} // Mode control bool mode(wifi_mode_t mode) { @@ -121,6 +121,8 @@ class MockWiFi { int8_t RSSI() const { return rssi_; } + String macAddress() const { return macAddress_; } + // Scan methods int16_t scanNetworks() { return networks_.size(); } @@ -155,6 +157,7 @@ class MockWiFi { void mockSetRSSI(int8_t rssi) { rssi_ = rssi; } void mockSetLocalIP(IPAddress ip) { localIP_ = ip; } void mockSetLocalIP(uint8_t a, uint8_t b, uint8_t c, uint8_t d) { localIP_ = IPAddress(a, b, c, d); } + void mockSetMacAddress(const char* mac) { macAddress_ = mac ? mac : ""; } void mockSetNetworks(const std::vector& networks) { networks_ = networks; } @@ -166,6 +169,7 @@ class MockWiFi { rssi_ = -70; localIP_ = IPAddress(192, 168, 1, 100); ssid_ = ""; + macAddress_ = "AA:BB:CC:DD:EE:FF"; networks_.clear(); } @@ -176,6 +180,7 @@ class MockWiFi { int8_t rssi_; IPAddress localIP_; String ssid_; + String macAddress_; std::vector networks_; }; From 21b2c9d738d040bcc6cff3b7e1c237cd4c51e256 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 5 Feb 2026 14:51:45 +1100 Subject: [PATCH 09/14] fix: Address additional review feedback - Remove unused controllerId destructure in subscriptions.ts (use controller.id from DB query instead) - Add pinMode initialization for button pins in setup() to ensure proper INPUT_PULLUP configuration before digitalRead calls Co-Authored-By: Claude Opus 4.5 --- embedded/projects/board-controller/src/main.cpp | 4 ++++ .../src/graphql/resolvers/controller/subscriptions.ts | 9 +++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/embedded/projects/board-controller/src/main.cpp b/embedded/projects/board-controller/src/main.cpp index 6d1336d2..0f652c1c 100644 --- a/embedded/projects/board-controller/src/main.cpp +++ b/embedded/projects/board-controller/src/main.cpp @@ -153,6 +153,10 @@ void setup() { #ifdef ENABLE_DISPLAY Display.setBleStatus(true, false); // BLE enabled, not connected + + // Initialize button pins for navigation + pinMode(BUTTON_1_PIN, INPUT_PULLUP); + pinMode(BUTTON_2_PIN, INPUT_PULLUP); #endif // Initialize web config server diff --git a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts index c760b3e5..ab883424 100644 --- a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts +++ b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts @@ -97,13 +97,14 @@ export const controllerSubscriptions = { ctx: ConnectionContext ): AsyncGenerator<{ controllerEvents: ControllerEvent }> { // Validate API key from context and verify session authorization - const { controllerId } = await requireControllerAuthorizedForSession(ctx, sessionId); + // This throws if the controller is not authorized + await requireControllerAuthorizedForSession(ctx, sessionId); - // Get controller details + // Get controller details using the controllerId from context (validated above) const [controller] = await db .select() .from(esp32Controllers) - .where(eq(esp32Controllers.id, controllerId)) + .where(eq(esp32Controllers.id, ctx.controllerId!)) .limit(1); if (!controller) { @@ -216,7 +217,7 @@ export const controllerSubscriptions = { : null; if (queueEvent.__typename === 'CurrentClimbChanged') { - console.log(`[Controller] CurrentClimbChanged event - clientId: ${eventClientId}, controllerId: ${controllerId}`); + console.log(`[Controller] CurrentClimbChanged event - clientId: ${eventClientId}, controllerId: ${controller.id}`); } const currentItem = queueEvent.__typename === 'CurrentClimbChanged' From c1ee6163e59fed9c3fb6a3ebc025b4ea7cea62a8 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 5 Feb 2026 17:03:20 +1100 Subject: [PATCH 10/14] fix: Address code review feedback - event ordering and heap fragmentation Backend: - Fix async IIFE race condition in subscriptions by using event queue to ensure QueueSync arrives before LedUpdate at ESP32 - Rename newIndex to targetIndex in navigateQueue for clarity - Add tests for concurrent navigation and error handling Embedded: - Use static buffer for queue sync to prevent heap fragmentation - Add hexToRgb565Fast() helper to avoid String allocations - Improve heap allocation error logging with size and free heap info Co-Authored-By: Claude Opus 4.5 --- .../src/graphql_ws_client.cpp | 5 +- .../projects/board-controller/src/main.cpp | 72 +++++--- .../src/__tests__/navigate-queue.test.ts | 171 ++++++++++++++++++ .../graphql/resolvers/controller/mutations.ts | 26 +-- .../resolvers/controller/subscriptions.ts | 15 +- 5 files changed, 241 insertions(+), 48 deletions(-) diff --git a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp index 370fdfa5..fb826fee 100644 --- a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp +++ b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp @@ -415,9 +415,10 @@ void GraphQLWSClient::handleQueueSync(JsonObject& data) { } // Allocate on heap to avoid stack overflow (~19KB struct) - ControllerQueueSyncData* syncData = new ControllerQueueSyncData(); + ControllerQueueSyncData* syncData = new (std::nothrow) ControllerQueueSyncData(); if (!syncData) { - Logger.logln("GraphQL: Failed to allocate QueueSync data"); + Logger.logln("GraphQL: CRITICAL: Failed to allocate QueueSync data (%u bytes, free heap: %u)", + (unsigned)sizeof(ControllerQueueSyncData), (unsigned)ESP.getFreeHeap()); return; } diff --git a/embedded/projects/board-controller/src/main.cpp b/embedded/projects/board-controller/src/main.cpp index 0f652c1c..764d42eb 100644 --- a/embedded/projects/board-controller/src/main.cpp +++ b/embedded/projects/board-controller/src/main.cpp @@ -39,6 +39,36 @@ String currentClimbName = ""; String currentGrade = ""; String boardType = "kilter"; bool hasCurrentClimb = false; + +// Static buffer for queue sync to avoid heap fragmentation +// LocalQueueItem is ~88 bytes each, so 150 items = ~13KB +static LocalQueueItem g_queueSyncBuffer[MAX_QUEUE_SIZE]; + +/** + * Convert hex color string to RGB565 without String allocations + * @param hex Color string in format "#RRGGBB" + * @return RGB565 color value, or 0xFFFF (white) if invalid + */ +static uint16_t hexToRgb565Fast(const char* hex) { + if (!hex || hex[0] != '#' || strlen(hex) < 7) { + return 0xFFFF; // White default + } + + // Parse hex digits directly without String objects + char buf[3] = {0, 0, 0}; + + buf[0] = hex[1]; buf[1] = hex[2]; + uint8_t r = strtol(buf, NULL, 16); + + buf[0] = hex[3]; buf[1] = hex[4]; + uint8_t g = strtol(buf, NULL, 16); + + buf[0] = hex[5]; buf[1] = hex[6]; + uint8_t b = strtol(buf, NULL, 16); + + // Convert to RGB565: 5 bits red, 6 bits green, 5 bits blue + return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); +} #endif // Forward declarations @@ -489,47 +519,33 @@ void onGraphQLMessage(JsonDocument& doc) { #ifdef ENABLE_DISPLAY /** * Handle queue sync event from backend + * Uses static buffer to avoid heap fragmentation from repeated allocations */ void onQueueSync(const ControllerQueueSyncData& data) { Logger.logln("Queue sync: %d items, currentIndex: %d", data.count, data.currentIndex); - // Allocate on heap to avoid stack overflow (LocalQueueItem is ~88 bytes each) + // Use static buffer to avoid heap fragmentation int itemCount = min(data.count, MAX_QUEUE_SIZE); - LocalQueueItem* items = new LocalQueueItem[itemCount]; - if (!items) { - Logger.logln("Queue sync: Failed to allocate memory for %d items", itemCount); - return; - } for (int i = 0; i < itemCount; i++) { - strncpy(items[i].uuid, data.items[i].uuid, sizeof(items[i].uuid) - 1); - items[i].uuid[sizeof(items[i].uuid) - 1] = '\0'; + strncpy(g_queueSyncBuffer[i].uuid, data.items[i].uuid, sizeof(g_queueSyncBuffer[i].uuid) - 1); + g_queueSyncBuffer[i].uuid[sizeof(g_queueSyncBuffer[i].uuid) - 1] = '\0'; - strncpy(items[i].climbUuid, data.items[i].climbUuid, sizeof(items[i].climbUuid) - 1); - items[i].climbUuid[sizeof(items[i].climbUuid) - 1] = '\0'; + strncpy(g_queueSyncBuffer[i].climbUuid, data.items[i].climbUuid, sizeof(g_queueSyncBuffer[i].climbUuid) - 1); + g_queueSyncBuffer[i].climbUuid[sizeof(g_queueSyncBuffer[i].climbUuid) - 1] = '\0'; - strncpy(items[i].name, data.items[i].name, sizeof(items[i].name) - 1); - items[i].name[sizeof(items[i].name) - 1] = '\0'; + strncpy(g_queueSyncBuffer[i].name, data.items[i].name, sizeof(g_queueSyncBuffer[i].name) - 1); + g_queueSyncBuffer[i].name[sizeof(g_queueSyncBuffer[i].name) - 1] = '\0'; - strncpy(items[i].grade, data.items[i].grade, sizeof(items[i].grade) - 1); - items[i].grade[sizeof(items[i].grade) - 1] = '\0'; + strncpy(g_queueSyncBuffer[i].grade, data.items[i].grade, sizeof(g_queueSyncBuffer[i].grade) - 1); + g_queueSyncBuffer[i].grade[sizeof(g_queueSyncBuffer[i].grade) - 1] = '\0'; - // Convert hex color to RGB565 if provided - if (data.items[i].gradeColor[0] == '#') { - items[i].gradeColorRgb = Display.getDisplay().color565( - strtol(String(data.items[i].gradeColor).substring(1, 3).c_str(), NULL, 16), - strtol(String(data.items[i].gradeColor).substring(3, 5).c_str(), NULL, 16), - strtol(String(data.items[i].gradeColor).substring(5, 7).c_str(), NULL, 16)); - } else { - items[i].gradeColorRgb = 0xFFFF; // White default - } + // Convert hex color to RGB565 using fast helper (no String allocations) + g_queueSyncBuffer[i].gradeColorRgb = hexToRgb565Fast(data.items[i].gradeColor); } - // Update display queue state - Display.setQueueFromSync(items, itemCount, data.currentIndex); - - // Free heap memory - delete[] items; + // Update display queue state (no delete needed - using static buffer) + Display.setQueueFromSync(g_queueSyncBuffer, itemCount, data.currentIndex); Logger.logln("Queue sync complete: stored %d items, index %d", Display.getQueueCount(), Display.getCurrentQueueIndex()); diff --git a/packages/backend/src/__tests__/navigate-queue.test.ts b/packages/backend/src/__tests__/navigate-queue.test.ts index 5f0a4b72..260ed0b8 100644 --- a/packages/backend/src/__tests__/navigate-queue.test.ts +++ b/packages/backend/src/__tests__/navigate-queue.test.ts @@ -529,6 +529,177 @@ describe('navigateQueue mutation', () => { }); }); + describe('Concurrent navigation', () => { + it('should handle rapid successive navigation requests', async () => { + const items = Array.from({ length: 5 }, (_, i) => + createMockQueueItem({ climb: createMockClimb({ name: `Climb ${i + 1}` }) }) + ); + const queue = items; + + // Start at first item + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: items[0], + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + + let sequenceCounter = 1; + updateQueueStateSpy.mockImplementation(async () => { + sequenceCounter++; + return { version: sequenceCounter, sequence: sequenceCounter, stateHash: `hash-${sequenceCounter}` }; + }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + // Fire 3 navigation requests concurrently + const results = await Promise.all([ + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ), + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ), + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ), + ]); + + // All requests should complete successfully + expect(results.every((r) => r !== null)).toBe(true); + + // updateQueueState should have been called 3 times + expect(updateQueueStateSpy).toHaveBeenCalledTimes(3); + }); + + it('should serialize concurrent requests to the same target', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockResolvedValue({ version: 2, sequence: 2, stateHash: 'new-hash' }); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + // Navigate to same target item concurrently + const results = await Promise.all([ + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next', queueItemUuid: item2.uuid }, + controllerCtx + ), + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next', queueItemUuid: item2.uuid }, + controllerCtx + ), + ]); + + // Both should successfully navigate to the same item + expect(results[0]?.uuid).toBe(item2.uuid); + expect(results[1]?.uuid).toBe(item2.uuid); + }); + }); + + describe('Error handling', () => { + it('should propagate error when updateQueueState fails', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockRejectedValue(new Error('Update failed')); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + await expect( + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ) + ).rejects.toThrow('Update failed'); + }); + + it('should not publish event when updateQueueState fails', async () => { + const item1 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 1' }) }); + const item2 = createMockQueueItem({ climb: createMockClimb({ name: 'Climb 2' }) }); + const queue = [item1, item2]; + + getQueueStateSpy.mockResolvedValue({ + queue, + currentClimbQueueItem: item1, + version: 1, + sequence: 1, + stateHash: 'test-hash', + }); + updateQueueStateSpy.mockRejectedValue(new Error('Update failed')); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + try { + await controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ); + } catch { + // Expected to throw + } + + expect(publishSpy).not.toHaveBeenCalled(); + }); + + it('should handle getQueueState failure gracefully', async () => { + getQueueStateSpy.mockRejectedValue(new Error('Failed to get queue state')); + + const controllerCtx = createControllerContext( + registeredController.controllerId, + registeredController.apiKey + ); + + await expect( + controllerMutations.navigateQueue( + undefined, + { sessionId: TEST_SESSION_ID, direction: 'next' }, + controllerCtx + ) + ).rejects.toThrow('Failed to get queue state'); + }); + }); + describe('Authorization', () => { it('should require controller authentication', async () => { // User context without controller auth diff --git a/packages/backend/src/graphql/resolvers/controller/mutations.ts b/packages/backend/src/graphql/resolvers/controller/mutations.ts index ed5bf1ea..69b39979 100644 --- a/packages/backend/src/graphql/resolvers/controller/mutations.ts +++ b/packages/backend/src/graphql/resolvers/controller/mutations.ts @@ -387,17 +387,17 @@ export const controllerMutations = { } let newCurrentClimb: ClimbQueueItem; - let newIndex: number; + let targetIndex: number; // If queueItemUuid is provided, navigate directly to that item (preferred method) if (queueItemUuid) { - newIndex = queue.findIndex((item) => item.uuid === queueItemUuid); - if (newIndex === -1) { + targetIndex = queue.findIndex((item) => item.uuid === queueItemUuid); + if (targetIndex === -1) { console.log(`[Controller] Navigate: queueItemUuid ${queueItemUuid} not found in queue`); // Fall back to direction-based navigation } else { - newCurrentClimb = queue[newIndex]; - console.log(`[Controller] Navigate: direct to queueItemUuid ${queueItemUuid}, index ${newIndex}, climb: ${newCurrentClimb.climb.name}`); + newCurrentClimb = queue[targetIndex]; + console.log(`[Controller] Navigate: direct to queueItemUuid ${queueItemUuid}, index ${targetIndex}, climb: ${newCurrentClimb.climb.name}`); // Update queue state const { sequence } = await roomManager.updateQueueState(sessionId, queue, newCurrentClimb); @@ -426,40 +426,40 @@ export const controllerMutations = { console.log(`[Controller] Navigate ${direction}: using referenceUuid=${referenceUuid} (from ESP32: ${!!currentClimbUuid}), found at index ${currentIndex}`); - // Calculate new index + // Calculate target index based on direction if (direction === 'next') { // Move forward in queue if (currentIndex === -1) { // No current climb, start at beginning - newIndex = 0; + targetIndex = 0; } else if (currentIndex >= queue.length - 1) { // Already at end, stay there console.log(`[Controller] Navigate next: already at end of queue`); return queue[currentIndex]; // Return the climb at current index } else { - newIndex = currentIndex + 1; + targetIndex = currentIndex + 1; } } else { // Move backward in queue (previous) if (currentIndex === -1) { // No current climb, start at end - newIndex = queue.length - 1; + targetIndex = queue.length - 1; } else if (currentIndex <= 0) { // Already at beginning, stay there console.log(`[Controller] Navigate previous: already at start of queue`); return queue[currentIndex]; // Return the climb at current index } else { - newIndex = currentIndex - 1; + targetIndex = currentIndex - 1; } } // Get the new current climb - newCurrentClimb = queue[newIndex]; + newCurrentClimb = queue[targetIndex]; console.log( - `[Controller] Navigate ${direction}: index ${currentIndex} -> ${newIndex}, climb: ${newCurrentClimb.climb.name} (queueItem uuid: ${newCurrentClimb.uuid})` + `[Controller] Navigate ${direction}: index ${currentIndex} -> ${targetIndex}, climb: ${newCurrentClimb.climb.name} (queueItem uuid: ${newCurrentClimb.uuid})` ); - console.log(`[Controller] Queue has ${queue.length} items, updating current to index ${newIndex}`); + console.log(`[Controller] Queue has ${queue.length} items, updating current to index ${targetIndex}`); // Update queue state (keep the same queue, just change current climb) const { sequence } = await roomManager.updateQueueState(sessionId, queue, newCurrentClimb); diff --git a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts index ab883424..be9e7811 100644 --- a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts +++ b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts @@ -184,6 +184,10 @@ export const controllerSubscriptions = { // Create subscription to queue events const asyncIterator = await createAsyncIterator((push) => { + // Event queue to ensure events are processed and sent in order + // This prevents race conditions where QueueSync and LedUpdate arrive out of order + let eventQueue: Promise = Promise.resolve(); + // Subscribe to queue updates for this session return pubsub.subscribeQueue(sessionId, (queueEvent) => { console.log(`[Controller] Received queue event: ${queueEvent.__typename}`); @@ -192,7 +196,8 @@ export const controllerSubscriptions = { if (queueEvent.__typename === 'QueueItemAdded' || queueEvent.__typename === 'QueueItemRemoved' || queueEvent.__typename === 'QueueReordered') { - (async () => { + // Queue the async work to ensure ordering + eventQueue = eventQueue.then(async () => { try { const queueState = await roomManager.getQueueState(sessionId); const queueSync = buildControllerQueueSync( @@ -204,7 +209,7 @@ export const controllerSubscriptions = { } catch (error) { console.error(`[Controller] Error building queue sync:`, error); } - })(); + }); return; } @@ -225,8 +230,8 @@ export const controllerSubscriptions = { : queueEvent.state.currentClimbQueueItem; const climb = currentItem?.climb; - // Handle async work with proper error handling - (async () => { + // Queue the async work to ensure ordering + eventQueue = eventQueue.then(async () => { try { if (climb) { console.log(`[Controller] Sending LED update for climb: ${climb.name} (uuid: ${currentItem?.uuid})`); @@ -243,7 +248,7 @@ export const controllerSubscriptions = { } catch (error) { console.error(`[Controller] Error building LED update:`, error); } - })(); + }); } }); }); From 33aa7c9a2db9419682d5b66c5237ad189ea30cb0 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 5 Feb 2026 17:06:00 +1100 Subject: [PATCH 11/14] fix: Wrap ESP.getFreeHeap() in platform check for native tests Co-Authored-By: Claude Opus 4.5 --- embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp index fb826fee..383044eb 100644 --- a/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp +++ b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp @@ -417,8 +417,13 @@ void GraphQLWSClient::handleQueueSync(JsonObject& data) { // Allocate on heap to avoid stack overflow (~19KB struct) ControllerQueueSyncData* syncData = new (std::nothrow) ControllerQueueSyncData(); if (!syncData) { +#ifdef ESP_PLATFORM Logger.logln("GraphQL: CRITICAL: Failed to allocate QueueSync data (%u bytes, free heap: %u)", (unsigned)sizeof(ControllerQueueSyncData), (unsigned)ESP.getFreeHeap()); +#else + Logger.logln("GraphQL: CRITICAL: Failed to allocate QueueSync data (%u bytes)", + (unsigned)sizeof(ControllerQueueSyncData)); +#endif return; } From 294018fa6ae227d54d7f8db083104a0dbe0b1738 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 5 Feb 2026 17:11:04 +1100 Subject: [PATCH 12/14] fix: Address additional review feedback - Reset connectionInitiated flag on all BLE proxy failure paths (empty scan results, target not found, proxy disabled) - Reduce verbose logging in controller subscriptions - Document ControllerQueueSync event, clientId field, and navigateQueue mutation Co-Authored-By: Claude Opus 4.5 --- docs/websocket-implementation.md | 42 +++++++++++++++++++ embedded/libs/ble-proxy/src/ble_proxy.cpp | 6 +++ .../resolvers/controller/subscriptions.ts | 17 -------- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/docs/websocket-implementation.md b/docs/websocket-implementation.md index 0421dd71..686508b5 100644 --- a/docs/websocket-implementation.md +++ b/docs/websocket-implementation.md @@ -952,6 +952,7 @@ subscription ControllerEvents($sessionId: ID!) { | Event | Description | |-------|-------------| | `LedUpdate` | LED commands for current climb (RGB values and positions) | +| `ControllerQueueSync` | Full queue state sync (sent on connection and queue changes) | | `ControllerPing` | Keep-alive ping (not currently implemented) | ### LedUpdate Fields @@ -959,11 +960,22 @@ subscription ControllerEvents($sessionId: ID!) { | Field | Type | Description | |-------|------|-------------| | `commands` | Array | LED positions with RGB values | +| `queueItemUuid` | String | UUID of the queue item (for navigation) | | `climbUuid` | String | Unique identifier for the climb | | `climbName` | String | Display name of the climb | | `climbGrade` | String | The climb difficulty/grade (e.g., "V5", "6a/V3") | +| `gradeColor` | String | Hex color for the grade (e.g., "#00FF00") | | `boardPath` | String | Board configuration path for context-aware operations (e.g., "kilter/1/12/1,2,3/40") | | `angle` | Int | Board angle in degrees | +| `clientId` | String | Identifier of client that initiated the change (used by ESP32 to decide whether to disconnect BLE client - if clientId matches ESP32's MAC, it was self-initiated via BLE) | +| `navigation` | Object | Navigation context with previousClimbs, nextClimb, currentIndex, totalCount | + +### ControllerQueueSync Fields + +| Field | Type | Description | +|-------|------|-------------| +| `queue` | Array | Array of queue items with uuid, climbUuid, name, grade, gradeColor | +| `currentIndex` | Int | Index of the current climb in the queue (-1 if none) | ### LED Color Mapping @@ -986,12 +998,42 @@ mutation SetClimbFromLeds($sessionId: ID!, $frames: String) { } } +# Navigate queue via hardware buttons (previous/next) +# queueItemUuid is preferred for direct navigation (most reliable) +# direction is used as fallback when queueItemUuid not found +mutation NavigateQueue($sessionId: ID!, $direction: String!, $queueItemUuid: String) { + navigateQueue(sessionId: $sessionId, direction: $direction, queueItemUuid: $queueItemUuid) { + uuid + climb { + name + difficulty + } + } +} + # Heartbeat to update lastSeenAt mutation Heartbeat($sessionId: ID!) { controllerHeartbeat(sessionId: $sessionId) } ``` +### Queue Navigation + +The `navigateQueue` mutation allows ESP32 controllers to browse the queue via hardware buttons: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `sessionId` | ID! | Session to navigate within | +| `direction` | String! | "next" or "previous" (fallback if queueItemUuid not found) | +| `queueItemUuid` | String | Direct navigation to specific queue item (preferred) | + +**Navigation Flow:** +1. ESP32 maintains local queue state from `ControllerQueueSync` events +2. On button press, ESP32 calculates the target item locally (optimistic update) +3. ESP32 sends `navigateQueue` with the target `queueItemUuid` +4. Backend updates current climb and broadcasts `CurrentClimbChanged` +5. ESP32 receives `LedUpdate` with new climb data + ### Manual Authorization If auto-authorization doesn't apply (e.g., controller owner is not in session), use: diff --git a/embedded/libs/ble-proxy/src/ble_proxy.cpp b/embedded/libs/ble-proxy/src/ble_proxy.cpp index 55fda46f..d5dbbc03 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.cpp +++ b/embedded/libs/ble-proxy/src/ble_proxy.cpp @@ -86,6 +86,8 @@ void BLEProxy::setEnabled(bool enable) { Logger.logln("BLEProxy: Disabling proxy mode"); BoardClient.disconnect(); Scanner.stopScan(); + // Reset connection flag when disabling + connectionInitiated = false; setState(BLEProxyState::PROXY_DISABLED); } } @@ -256,6 +258,8 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { if (boards.empty()) { Logger.logln("BLEProxy: No boards found (reboot to scan again)"); + // Reset connection flag so future scans can initiate connections + connectionInitiated = false; setState(BLEProxyState::SCAN_COMPLETE_NONE); return; } @@ -295,6 +299,8 @@ void BLEProxy::handleScanComplete(const std::vector& boards) { waitDuration = 100; setState(BLEProxyState::WAIT_BEFORE_CONNECT); } else { + // Reset connection flag so next scan can initiate a new connection + connectionInitiated = false; setState(BLEProxyState::IDLE); } } diff --git a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts index be9e7811..329d0329 100644 --- a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts +++ b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts @@ -161,10 +161,7 @@ export const controllerSubscriptions = { // Get queue state for navigation context const queueState = await roomManager.getQueueState(sessionId); - console.log(`[Controller] buildLedUpdate: queueState has ${queueState.queue.length} items, currentClimb: ${queueState.currentClimbQueueItem?.climb?.name} (uuid: ${queueState.currentClimbQueueItem?.uuid})`); - console.log(`[Controller] buildLedUpdate: looking for currentItemUuid: ${currentItemUuid}`); const currentIndex = findClimbIndex(queueState.queue, currentItemUuid); - console.log(`[Controller] buildLedUpdate: found at index ${currentIndex}`); const navigation = buildNavigationContext(queueState.queue, currentIndex); return { @@ -190,7 +187,6 @@ export const controllerSubscriptions = { // Subscribe to queue updates for this session return pubsub.subscribeQueue(sessionId, (queueEvent) => { - console.log(`[Controller] Received queue event: ${queueEvent.__typename}`); // Handle queue modification events - send ControllerQueueSync if (queueEvent.__typename === 'QueueItemAdded' || @@ -204,7 +200,6 @@ export const controllerSubscriptions = { queueState.queue, queueState.currentClimbQueueItem?.uuid ); - console.log(`[Controller] Sending QueueSync: ${queueSync.queue.length} items, currentIndex: ${queueSync.currentIndex}`); push(queueSync); } catch (error) { console.error(`[Controller] Error building queue sync:`, error); @@ -221,10 +216,6 @@ export const controllerSubscriptions = { ? queueEvent.clientId : null; - if (queueEvent.__typename === 'CurrentClimbChanged') { - console.log(`[Controller] CurrentClimbChanged event - clientId: ${eventClientId}, controllerId: ${controller.id}`); - } - const currentItem = queueEvent.__typename === 'CurrentClimbChanged' ? queueEvent.item : queueEvent.state.currentClimbQueueItem; @@ -234,14 +225,10 @@ export const controllerSubscriptions = { eventQueue = eventQueue.then(async () => { try { if (climb) { - console.log(`[Controller] Sending LED update for climb: ${climb.name} (uuid: ${currentItem?.uuid})`); const ledUpdate = await buildLedUpdateWithNavigation(climb, currentItem?.uuid, eventClientId); - console.log(`[Controller] LED update navigation: index ${ledUpdate.navigation?.currentIndex}/${ledUpdate.navigation?.totalCount}, climb: ${ledUpdate.climbName}, clientId: ${ledUpdate.clientId}`); - console.log(`[Controller] Pushing LED update to subscription`); push(ledUpdate); } else { // No climb - could be clearing or unknown climb - console.log(`[Controller] Sending update with no climb (clientId: ${eventClientId})`); const ledUpdate = await buildLedUpdateWithNavigation(null, undefined, eventClientId); push(ledUpdate); } @@ -259,7 +246,6 @@ export const controllerSubscriptions = { initialQueueState.queue, initialQueueState.currentClimbQueueItem?.uuid ); - console.log(`[Controller] Sending initial QueueSync: ${initialQueueSync.queue.length} items, currentIndex: ${initialQueueSync.currentIndex}`); yield { controllerEvents: initialQueueSync }; // Send initial LED state @@ -271,9 +257,7 @@ export const controllerSubscriptions = { yield { controllerEvents: initialLedUpdate }; // Yield events from subscription - console.log(`[Controller] Starting to yield events for controller ${controller.id}`); for await (const event of asyncIterator) { - console.log(`[Controller] Yielding event to client: ${event.__typename}`); // Update lastSeenAt periodically (on each event) await db .update(esp32Controllers) @@ -282,7 +266,6 @@ export const controllerSubscriptions = { yield { controllerEvents: event }; } - console.log(`[Controller] Subscription loop ended for controller ${controller.id}`); }, }, }; From 0fb1bcfcebd3a367f782bac4a1e5fd935c569b60 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 5 Feb 2026 17:24:42 +1100 Subject: [PATCH 13/14] fix: Call connect callback on BLE connection failure - BLE client now calls connectCallback(false) when connect() fails synchronously, so the proxy gets notified and can transition to RECONNECTING state instead of being stuck in CONNECTING - Add debug logging in scanner to help diagnose board detection issues Co-Authored-By: Claude Opus 4.5 --- embedded/libs/ble-proxy/src/ble_client.cpp | 4 ++++ embedded/libs/ble-proxy/src/ble_scanner.cpp | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/embedded/libs/ble-proxy/src/ble_client.cpp b/embedded/libs/ble-proxy/src/ble_client.cpp index 487736e5..d9457ad2 100644 --- a/embedded/libs/ble-proxy/src/ble_client.cpp +++ b/embedded/libs/ble-proxy/src/ble_client.cpp @@ -50,6 +50,10 @@ bool BLEClientConnection::connect(NimBLEAddress address) { Logger.logln("BLEClient: connect() returned false"); state = BLEClientState::DISCONNECTED; reconnectTime = millis() + CLIENT_RECONNECT_DELAY_MS; + // Notify callback of connection failure so proxy can update state + if (connectCallback) { + connectCallback(false); + } return false; } diff --git a/embedded/libs/ble-proxy/src/ble_scanner.cpp b/embedded/libs/ble-proxy/src/ble_scanner.cpp index b6a87d98..b78477ef 100644 --- a/embedded/libs/ble-proxy/src/ble_scanner.cpp +++ b/embedded/libs/ble-proxy/src/ble_scanner.cpp @@ -89,6 +89,23 @@ const DiscoveredBoard* BLEScanner::findByAddress(const String& mac) const { } void BLEScanner::onResult(NimBLEAdvertisedDevice* advertisedDevice) { + // Get device name early for debugging and later use + String name = advertisedDevice->getName().c_str(); + + // Log device name if it looks like an Aurora board (for debugging) + if (name.length() > 0 && (name.indexOf("Kilter") >= 0 || name.indexOf("Tension") >= 0 || + name.indexOf("Decoy") >= 0 || name.indexOf("Touchstone") >= 0 || name.indexOf("Grasshopper") >= 0)) { + Logger.logln("BLEScanner: Potential Aurora device: %s (%s)", + name.c_str(), advertisedDevice->getAddress().toString().c_str()); + // Log service count for debugging + if (advertisedDevice->haveServiceUUID()) { + Logger.logln("BLEScanner: Has service UUID: %s", + advertisedDevice->getServiceUUID().toString().c_str()); + } else { + Logger.logln("BLEScanner: No service UUID advertised"); + } + } + // Check if this device advertises Aurora's service UUID if (!advertisedDevice->isAdvertisingService(NimBLEUUID(AURORA_ADVERTISED_SERVICE_UUID))) { return; @@ -100,8 +117,6 @@ void BLEScanner::onResult(NimBLEAdvertisedDevice* advertisedDevice) { return; // Already in list } } - - String name = advertisedDevice->getName().c_str(); if (name.length() == 0) { name = "Unknown Board"; } From e4e6ff314c15368c53734bc95b6779f67be44f74 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Thu, 5 Feb 2026 17:29:50 +1100 Subject: [PATCH 14/14] chore: Remove debug logging from BLE scanner Removed temporary diagnostic logging that was added to debug board detection issues. The name is now only fetched after confirming the device is an Aurora board, which is more efficient. Co-Authored-By: Claude Opus 4.5 --- embedded/libs/ble-proxy/src/ble_scanner.cpp | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/embedded/libs/ble-proxy/src/ble_scanner.cpp b/embedded/libs/ble-proxy/src/ble_scanner.cpp index b78477ef..b6a87d98 100644 --- a/embedded/libs/ble-proxy/src/ble_scanner.cpp +++ b/embedded/libs/ble-proxy/src/ble_scanner.cpp @@ -89,23 +89,6 @@ const DiscoveredBoard* BLEScanner::findByAddress(const String& mac) const { } void BLEScanner::onResult(NimBLEAdvertisedDevice* advertisedDevice) { - // Get device name early for debugging and later use - String name = advertisedDevice->getName().c_str(); - - // Log device name if it looks like an Aurora board (for debugging) - if (name.length() > 0 && (name.indexOf("Kilter") >= 0 || name.indexOf("Tension") >= 0 || - name.indexOf("Decoy") >= 0 || name.indexOf("Touchstone") >= 0 || name.indexOf("Grasshopper") >= 0)) { - Logger.logln("BLEScanner: Potential Aurora device: %s (%s)", - name.c_str(), advertisedDevice->getAddress().toString().c_str()); - // Log service count for debugging - if (advertisedDevice->haveServiceUUID()) { - Logger.logln("BLEScanner: Has service UUID: %s", - advertisedDevice->getServiceUUID().toString().c_str()); - } else { - Logger.logln("BLEScanner: No service UUID advertised"); - } - } - // Check if this device advertises Aurora's service UUID if (!advertisedDevice->isAdvertisingService(NimBLEUUID(AURORA_ADVERTISED_SERVICE_UUID))) { return; @@ -117,6 +100,8 @@ void BLEScanner::onResult(NimBLEAdvertisedDevice* advertisedDevice) { return; // Already in list } } + + String name = advertisedDevice->getName().c_str(); if (name.length() == 0) { name = "Unknown Board"; }