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/aurora-protocol/src/aurora_protocol.cpp b/embedded/libs/aurora-protocol/src/aurora_protocol.cpp index 05c8dee8..4cc52b2d 100644 --- a/embedded/libs/aurora-protocol/src/aurora_protocol.cpp +++ b/embedded/libs/aurora-protocol/src/aurora_protocol.cpp @@ -239,6 +239,81 @@ 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; + + 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/ble-proxy/src/ble_client.cpp b/embedded/libs/ble-proxy/src/ble_client.cpp index 21e20017..d9457ad2 100644 --- a/embedded/libs/ble-proxy/src/ble_client.cpp +++ b/embedded/libs/ble-proxy/src/ble_client.cpp @@ -23,31 +23,41 @@ 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; + // Notify callback of connection failure so proxy can update state + if (connectCallback) { + connectCallback(false); + } 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 249c2623..d5dbbc03 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,8 +37,15 @@ 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), + waitStartTime(0), waitDuration(0), stateCallback(nullptr), dataCallback(nullptr), sendToAppCallback(nullptr) { proxyInstance = this; } @@ -78,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); } } @@ -97,13 +107,53 @@ 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: Found board, preparing to connect to %s", pendingConnectName.c_str()); + + // Stop the scan (this may trigger handleScanComplete, but we check pendingConnectName) + Scanner.stopScan(); + + Logger.logln("BLEProxy: Addr: %s", pendingConnectAddress.toString().c_str()); + + // 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. + 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()); + + // Store connection info and clear pending + NimBLEAddress connectAddr = pendingConnectAddress; + pendingConnectName = ""; + + setState(BLEProxyState::CONNECTING); + BoardClient.connect(connectAddr); + } break; case BLEProxyState::CONNECTING: // 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; @@ -178,13 +228,39 @@ 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 + // 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 + pendingConnectAddress = board.address; + pendingConnectName = board.name; + } } 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, will retry"); - setState(BLEProxyState::IDLE); + 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; } @@ -204,20 +280,49 @@ 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()); - setState(BLEProxyState::CONNECTING); - BoardClient.connect(target->address); + // 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()); + + // Store connection info for the wait state + pendingConnectAddress = target->address; + pendingConnectName = target->name; + + // 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. + waitStartTime = millis(); + waitDuration = 100; + setState(BLEProxyState::WAIT_BEFORE_CONNECT); } else { + // Reset connection flag so next scan can initiate a new connection + connectionInitiated = false; setState(BLEProxyState::IDLE); } } void BLEProxy::handleBoardConnected(bool connected) { if (connected) { - Logger.logln("BLEProxy: Connected to board"); - setState(BLEProxyState::CONNECTED); + Logger.logln("BLEProxy: Connected to board!"); + + // Now that we're connected to the real board, start advertising + // 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"); + + // 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 11e72064..33a9630b 100644 --- a/embedded/libs/ble-proxy/src/ble_proxy.h +++ b/embedded/libs/ble-proxy/src/ble_proxy.h @@ -5,15 +5,19 @@ #include "ble_scanner.h" #include +#include // 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) + 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); @@ -113,6 +117,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,10 +129,23 @@ 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; + ProxyStateCallback stateCallback; 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 5f296e79..b6a87d98 100644 --- a/embedded/libs/ble-proxy/src/ble_scanner.cpp +++ b/embedded/libs/ble-proxy/src/ble_scanner.cpp @@ -119,8 +119,19 @@ 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()); + + // 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()); if (instance->completeCallback) { instance->completeCallback(instance->discoveredBoards); 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/libs/graphql-ws-client/src/graphql_ws_client.cpp b/embedded/libs/graphql-ws-client/src/graphql_ws_client.cpp index 30748002..383044eb 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 @@ -10,8 +11,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), + 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://) @@ -155,6 +156,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 +190,14 @@ void GraphQLWSClient::setStateCallback(GraphQLStateCallback callback) { stateCallback = callback; } +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: @@ -206,12 +241,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); @@ -245,6 +286,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"); } @@ -275,14 +318,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(); @@ -305,25 +358,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(); @@ -333,14 +384,88 @@ 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"); + } + + // Call LED update callback (for proxy forwarding) + if (ledUpdateCallback) { + ledUpdateCallback(ledCommands, count); } 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; + } + + // 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; + } + + 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); + + // Free heap memory + delete 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..f8cdbecb 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,25 @@ 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); +typedef void (*GraphQLLedUpdateCallback)(const LedCommand* commands, int count); class GraphQLWSClient { public: @@ -44,16 +61,24 @@ 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); + void setLedUpdateCallback(GraphQLLedUpdateCallback 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; @@ -62,11 +87,19 @@ 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; GraphQLMessageCallback messageCallback; GraphQLStateCallback stateCallback; + GraphQLQueueSyncCallback queueSyncCallback; + GraphQLLedUpdateCallback ledUpdateCallback; String serverHost; uint16_t serverPort; @@ -75,6 +108,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/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/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"); } 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 d1f68ee8..764d42eb 100644 --- a/embedded/projects/board-controller/src/main.cpp +++ b/embedded/projects/board-controller/src/main.cpp @@ -30,6 +30,47 @@ 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; + +// 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 void onWiFiStateChange(WiFiConnectionState state); void onBLEConnect(bool connected); @@ -39,12 +80,17 @@ 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(); #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) { @@ -113,7 +159,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); @@ -131,6 +183,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 @@ -167,6 +223,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,9 +312,16 @@ 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); +#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; } @@ -264,6 +393,42 @@ 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); + + // 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) { + // 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 %d chunks (%zu protocol packets) to board", totalChunks, packets.size()); +} #endif /** @@ -298,15 +463,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 +517,141 @@ 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); + + // Use static buffer to avoid heap fragmentation + int itemCount = min(data.count, MAX_QUEUE_SIZE); + + for (int i = 0; i < itemCount; i++) { + 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(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(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(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 using fast helper (no String allocations) + g_queueSyncBuffer[i].gradeColorRgb = hexToRgb565Fast(data.items[i].gradeColor); + } + + // 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()); +} + /** * 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 +660,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/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 be3efee2..00000000 --- a/embedded/projects/climb-queue-display/src/main.cpp +++ /dev/null @@ -1,468 +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 = ""; -bool hasCurrentClimb = false; - -// ============================================ -// Forward Declarations -// ============================================ - -void onWiFiStateChange(WiFiConnectionState state); -void onGraphQLStateChange(GraphQLConnectionState state); -void onGraphQLMessage(JsonDocument& doc); -void handleLedUpdate(JsonObject& data); - -#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 or start AP mode for setup - 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"); - } - } - - // 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 - static bool lastButton1 = HIGH; - static unsigned long button1PressTime = 0; - bool button1 = digitalRead(BUTTON_1_PIN); - - // Button 1 - long press (3 sec) to 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", ""); - - delay(1000); - ESP.restart(); - } - if (button1 == HIGH) { - button1PressTime = 0; - } - lastButton1 = button1; -} - -// ============================================ -// 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); - - // 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); - 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.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; - - case WiFiConnectionState::AP_MODE: - Logger.logln("WiFi AP mode active: %s", WiFiMgr.getAPIP().c_str()); - 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 } climbUuid climbName " - "climbGrade boardPath angle } " - "... 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); - } - } - } -} - -void handleLedUpdate(JsonObject& data) { - JsonArray commands = data["commands"]; - 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)", climbName ? climbName : "(none)", - climbGrade ? climbGrade : "?", angle, count); - - // 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; - 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 - currentClimbUuid = climbUuid ? climbUuid : ""; - currentClimbName = climbName ? climbName : ""; - currentGrade = climbGrade ? climbGrade : ""; - currentGradeColor = ""; // gradeColor not available from schema - hasCurrentClimb = true; - - // Update display (pass empty gradeColor - display will use default) - Display.showClimb(climbName, climbGrade, "", 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 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_; }; 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/__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..260ed0b8 --- /dev/null +++ b/packages/backend/src/__tests__/navigate-queue.test.ts @@ -0,0 +1,772 @@ +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('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 + 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 () => { 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/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/mutations.ts b/packages/backend/src/graphql/resolvers/controller/mutations.ts index 6853861d..69b39979 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'; @@ -193,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, @@ -257,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, }); @@ -342,6 +357,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 targetIndex: number; + + // If queueItemUuid is provided, navigate directly to that item (preferred method) + if (queueItemUuid) { + 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[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); + + // 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 target index based on direction + if (direction === 'next') { + // Move forward in queue + if (currentIndex === -1) { + // No current climb, start at beginning + 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 { + targetIndex = currentIndex + 1; + } + } else { + // Move backward in queue (previous) + if (currentIndex === -1) { + // No current climb, start at end + 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 { + targetIndex = currentIndex - 1; + } + } + + // Get the new current climb + newCurrentClimb = queue[targetIndex]; + + console.log( + `[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 ${targetIndex}`); + + // 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/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); +} diff --git a/packages/backend/src/graphql/resolvers/controller/subscriptions.ts b/packages/backend/src/graphql/resolvers/controller/subscriptions.ts index 05b89f20..329d0329 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 */ @@ -67,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) { @@ -94,85 +125,136 @@ 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, + clientId?: string | null + ): 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 - 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, + 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, + }; + } + + const commands = climbToLedCommands(climb, ledPlacements); + + // Get queue state for navigation context + const queueState = await roomManager.getQueueState(sessionId); + const currentIndex = findClimbIndex(queueState.queue, currentItemUuid); + 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, + clientId, + }; + }; + // 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}`); - // Only process current climb changes + // Handle queue modification events - send ControllerQueueSync + if (queueEvent.__typename === 'QueueItemAdded' || + queueEvent.__typename === 'QueueItemRemoved' || + queueEvent.__typename === 'QueueReordered') { + // Queue the async work to ensure ordering + eventQueue = eventQueue.then(async () => { + try { + const queueState = await roomManager.getQueueState(sessionId); + const queueSync = buildControllerQueueSync( + queueState.queue, + queueState.currentClimbQueueItem?.uuid + ); + push(queueSync); + } catch (error) { + console.error(`[Controller] Error building queue sync:`, error); + } + }); + return; + } + + // 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) - 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; + // Extract clientId from the event (null for FullSync or system-initiated changes) + const eventClientId = queueEvent.__typename === 'CurrentClimbChanged' + ? queueEvent.clientId + : null; + + const currentItem = queueEvent.__typename === 'CurrentClimbChanged' + ? queueEvent.item + : queueEvent.state.currentClimbQueueItem; + const climb = currentItem?.climb; + + // Queue the async work to ensure ordering + eventQueue = eventQueue.then(async () => { + try { + if (climb) { + const ledUpdate = await buildLedUpdateWithNavigation(climb, currentItem?.uuid, eventClientId); + push(ledUpdate); + } else { + // No climb - could be clearing or unknown climb + const ledUpdate = await buildLedUpdateWithNavigation(null, undefined, eventClientId); + push(ledUpdate); + } + } catch (error) { + console.error(`[Controller] Error building LED update:`, error); } - } - - 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); - } + }); } }); }); - // 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 + ); + 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 for await (const event of asyncIterator) { @@ -199,6 +281,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/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 30b7a33d..b8358b88 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,39 @@ 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 + "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 @@ -1443,8 +1474,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..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) }; // ============================================ @@ -345,13 +346,36 @@ 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; + // 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 @@ -360,8 +384,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 = {