#include #include #include #include #include #include #include #include #include namespace { constexpr uint8_t MAX_CREDENTIALS = 10; constexpr uint8_t MAX_BLUETOOTH_DEVICES = 24; constexpr uint8_t DNS_PORT = 53; constexpr uint8_t AP_CHANNEL = 6; constexpr uint8_t AP_MAX_CLIENTS = 4; constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; constexpr uint32_t RETRY_INTERVAL_MS = 30000; constexpr uint32_t DIAGNOSTIC_INTERVAL_MS = 10000; constexpr uint32_t BLUETOOTH_SCAN_SECONDS = 5; constexpr uint32_t BLUETOOTH_START_DELAY_MS = 2000; constexpr uint32_t SERVER_DISCONNECTED_POLL_INTERVAL_MS = 5000; constexpr uint32_t SERVER_WEBSOCKET_FALLBACK_DELAY_MS = 15000; constexpr uint32_t MATRIX_REFRESH_INTERVAL_MS = 10000; constexpr uint16_t MATRIX_SIZE = 64; constexpr size_t MATRIX_FRAME_BYTES = MATRIX_SIZE * MATRIX_SIZE * 3; constexpr size_t MATRIX_HTTP_CHUNK_BYTES = 768; constexpr size_t MATRIX_HTTP_ENCODED_CHUNK_BYTES = 1024; constexpr size_t MATRIX_HTTP_CHUNK_COUNT = MATRIX_FRAME_BYTES / MATRIX_HTTP_CHUNK_BYTES; constexpr size_t MATRIX_CHUNK_BYTES = 4096; constexpr size_t MATRIX_WEBSOCKET_CHUNK_BYTES = 4096; constexpr uint32_t LED_CONNECTED_INTERVAL_MS = 2000; constexpr uint32_t LED_DISCONNECTED_INTERVAL_MS = 200; #if CONFIG_IDF_TARGET_ESP32C3 constexpr uint8_t STATUS_LED_PIN = 8; constexpr uint8_t LED_ON = LOW; constexpr uint8_t LED_OFF = HIGH; #else constexpr uint8_t STATUS_LED_PIN = 2; constexpr uint8_t LED_ON = HIGH; constexpr uint8_t LED_OFF = LOW; #endif constexpr char AP_PASSWORD[] = "configureme"; constexpr char DEFAULT_SERVER_URL[] = "https://naman.md"; constexpr char LEGACY_RAILWAY_SERVER_URL[] = "https://pixelsync-production.up.railway.app"; constexpr char LEGACY_VERCEL_SERVER_URL[] = "https://pixelsync-omega.vercel.app"; struct Credential { String ssid; String password; }; struct Candidate { uint8_t credentialIndex; int32_t rssi; }; struct BluetoothDeviceInfo { String name; String address; int rssi; uint8_t addressType; bool isIdotMatrix; }; Preferences preferences; Preferences settingsPreferences; WebServer server(80); DNSServer dnsServer; WebSocketsClient frameWebSocket; Ticker displaySyncLedTicker; NimBLEClient *matrixClient = nullptr; NimBLERemoteCharacteristic *matrixWriteCharacteristic = nullptr; NimBLERemoteCharacteristic *matrixNotifyCharacteristic = nullptr; Credential credentials[MAX_CREDENTIALS]; Candidate candidates[MAX_CREDENTIALS]; BluetoothDeviceInfo bluetoothDevices[MAX_BLUETOOTH_DEVICES]; uint8_t matrixFrame[MATRIX_FRAME_BYTES]; uint8_t matrixSentFrame[MATRIX_FRAME_BYTES] = {}; bool matrixChunkReceived[MATRIX_HTTP_CHUNK_COUNT] = {}; volatile uint8_t matrixUploadStatus = 0; QueueHandle_t matrixUploadStatusQueue = nullptr; uint8_t credentialCount = 0; uint8_t candidateCount = 0; uint8_t candidatePosition = 0; uint8_t bluetoothDeviceCount = 0; uint32_t attemptStartedAt = 0; uint32_t lastCycleFinishedAt = 0; uint32_t connectedAt = 0; uint32_t lastDiagnosticAt = 0; bool attemptInProgress = false; bool connectionCycleRequested = true; bool portalRunning = false; volatile bool ledOn = false; volatile bool displaySyncInProgress = false; bool previousConnectionState = false; bool bluetoothBusy = false; bool bluetoothScanRequested = true; bool helloWorldRequested = false; bool matrixRenderRequested = false; bool matrixConnectionRequested = false; bool matrixSentFrameKnown = false; bool matrixFrameKnown = false; bool matrixReconnectScanPending = false; bool frameWebSocketConfigured = false; bool frameWebSocketConnected = false; uint32_t matrixReconnectDelayMs = 5000; uint32_t lastLedToggleAt = 0; uint32_t lastMatrixConnectionAttemptAt = 0; uint32_t lastMatrixRenderAt = 0; uint32_t lastServerPollAt = 0; uint32_t frameWebSocketUnavailableSince = 0; String portalSsid; String serverUrl; String serverUsername; String serverPassword; String frameWebSocketHeaders; String currentFrameRevision; String announcedFrameRevision; size_t receivedWebSocketFrameBytes = 0; String renderedFrameRevision; String pendingAcknowledgementRevision; uint32_t renderedFrameCrc = 0; bool renderedFrameCrcKnown = false; bool serverFramePending = false; String bluetoothStatus = "Waiting for first scan"; String matrixConnectedAddress; String matrixLastError; String matrixKnownAddress; uint8_t matrixKnownAddressType = 0; int findIdotMatrixDevice(); bool matrixConnectionIsReady(); bool sendMatrixFrameToIdotMatrix(); void startPortal(); void configureFrameWebSocket(); void toggleDisplaySyncLed() { ledOn = !ledOn; digitalWrite(STATUS_LED_PIN, ledOn ? LED_ON : LED_OFF); } void startDisplaySyncIndicator() { if (displaySyncInProgress) return; displaySyncInProgress = true; ledOn = true; digitalWrite(STATUS_LED_PIN, LED_ON); displaySyncLedTicker.attach_ms(150, toggleDisplaySyncLed); } void stopDisplaySyncIndicator() { if (!displaySyncInProgress) return; displaySyncLedTicker.detach(); displaySyncInProgress = false; ledOn = false; digitalWrite(STATUS_LED_PIN, LED_OFF); lastLedToggleAt = millis(); } void updateStatusLed() { if (displaySyncInProgress) return; const bool connected = WiFi.status() == WL_CONNECTED; const uint32_t interval = connected ? LED_CONNECTED_INTERVAL_MS : LED_DISCONNECTED_INTERVAL_MS; const uint32_t now = millis(); if (connected != previousConnectionState) { previousConnectionState = connected; ledOn = true; lastLedToggleAt = now; digitalWrite(STATUS_LED_PIN, LED_ON); return; } if (now - lastLedToggleAt >= interval) { lastLedToggleAt = now; ledOn = !ledOn; digitalWrite(STATUS_LED_PIN, ledOn ? LED_ON : LED_OFF); } } String jsonEscape(const String &input) { String output; output.reserve(input.length() + 8); for (size_t i = 0; i < input.length(); ++i) { const char c = input[i]; switch (c) { case '\\': output += "\\\\"; break; case '"': output += "\\\""; break; case '\n': output += "\\n"; break; case '\r': output += "\\r"; break; case '\t': output += "\\t"; break; default: if (static_cast(c) >= 0x20) output += c; } } return output; } String base64Encode(const String &input) { static constexpr char ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; String output; output.reserve(((input.length() + 2) / 3) * 4); for (size_t i = 0; i < input.length(); i += 3) { const size_t remaining = input.length() - i; const uint32_t value = static_cast(input[i]) << 16 | (remaining > 1 ? static_cast(input[i + 1]) << 8 : 0) | (remaining > 2 ? static_cast(input[i + 2]) : 0); output += ALPHABET[(value >> 18) & 0x3f]; output += ALPHABET[(value >> 12) & 0x3f]; output += remaining > 1 ? ALPHABET[(value >> 6) & 0x3f] : '='; output += remaining > 2 ? ALPHABET[value & 0x3f] : '='; } return output; } bool serverCredentialsConfigured() { return !serverUsername.isEmpty() || !serverPassword.isEmpty(); } int base64Value(char character) { if (character >= 'A' && character <= 'Z') return character - 'A'; if (character >= 'a' && character <= 'z') return character - 'a' + 26; if (character >= '0' && character <= '9') return character - '0' + 52; if (character == '+') return 62; if (character == '/') return 63; return -1; } bool decodeMatrixChunk(const String &encoded, uint8_t *decoded) { if (encoded.length() != MATRIX_HTTP_ENCODED_CHUNK_BYTES) return false; size_t output = 0; for (size_t input = 0; input < encoded.length(); input += 4) { const int a = base64Value(encoded[input]); const int b = base64Value(encoded[input + 1]); const int c = base64Value(encoded[input + 2]); const int d = base64Value(encoded[input + 3]); if (a < 0 || b < 0 || c < 0 || d < 0) return false; decoded[output++] = (a << 2) | (b >> 4); decoded[output++] = (b << 4) | (c >> 2); decoded[output++] = (c << 6) | d; } return output == MATRIX_HTTP_CHUNK_BYTES; } bool isIdotMatrixName(const String &name) { return name.startsWith("IDM_") || name.startsWith("IDM-"); } uint8_t glyphRow(char character, uint8_t row) { static const uint8_t A[] = {0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}; static const uint8_t B[] = {0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E}; static const uint8_t C[] = {0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E}; static const uint8_t H[] = {0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}; static const uint8_t F[] = {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10}; static const uint8_t G[] = {0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E}; static const uint8_t I[] = {0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x1F}; static const uint8_t J[] = {0x07, 0x02, 0x02, 0x02, 0x12, 0x12, 0x0C}; static const uint8_t K[] = {0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11}; static const uint8_t M[] = {0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11}; static const uint8_t N[] = {0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11}; static const uint8_t P[] = {0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10}; static const uint8_t Q[] = {0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D}; static const uint8_t S[] = {0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E}; static const uint8_t T[] = {0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04}; static const uint8_t U[] = {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E}; static const uint8_t V[] = {0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04}; static const uint8_t X[] = {0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11}; static const uint8_t Y[] = {0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04}; static const uint8_t Z[] = {0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F}; static const uint8_t E[] = {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F}; static const uint8_t L[] = {0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F}; static const uint8_t O[] = {0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E}; static const uint8_t W[] = {0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A}; static const uint8_t R[] = {0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11}; static const uint8_t D[] = {0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E}; static const uint8_t ZERO[] = {0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E}; static const uint8_t ONE[] = {0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E}; static const uint8_t TWO[] = {0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F}; static const uint8_t THREE[] = {0x1E, 0x01, 0x01, 0x0E, 0x01, 0x01, 0x1E}; static const uint8_t FOUR[] = {0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02}; static const uint8_t FIVE[] = {0x1F, 0x10, 0x10, 0x1E, 0x01, 0x01, 0x1E}; static const uint8_t SIX[] = {0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E}; static const uint8_t SEVEN[] = {0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08}; static const uint8_t EIGHT[] = {0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E}; static const uint8_t NINE[] = {0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E}; if (row >= 7) return 0; switch (character) { case 'A': return A[row]; case 'B': return B[row]; case 'C': return C[row]; case 'F': return F[row]; case 'G': return G[row]; case 'I': return I[row]; case 'J': return J[row]; case 'K': return K[row]; case 'M': return M[row]; case 'N': return N[row]; case 'P': return P[row]; case 'Q': return Q[row]; case 'S': return S[row]; case 'T': return T[row]; case 'U': return U[row]; case 'V': return V[row]; case 'X': return X[row]; case 'Y': return Y[row]; case 'Z': return Z[row]; case 'H': return H[row]; case 'E': return E[row]; case 'L': return L[row]; case 'O': return O[row]; case 'W': return W[row]; case 'R': return R[row]; case 'D': return D[row]; case '0': return ZERO[row]; case '1': return ONE[row]; case '2': return TWO[row]; case '3': return THREE[row]; case '4': return FOUR[row]; case '5': return FIVE[row]; case '6': return SIX[row]; case '7': return SEVEN[row]; case '8': return EIGHT[row]; case '9': return NINE[row]; default: return 0; } } void setMatrixPixel(uint8_t *frame, int x, int y, uint8_t red, uint8_t green, uint8_t blue) { if (x < 0 || x >= MATRIX_SIZE || y < 0 || y >= MATRIX_SIZE) return; const size_t offset = (static_cast(y) * MATRIX_SIZE + x) * 3; frame[offset] = red; frame[offset + 1] = green; frame[offset + 2] = blue; } // The frame buffer is the generic rendering surface. Any effect, text, image, // or API call can set an individual logical pixel and its RGB colour here. void setFramePixel(int x, int y, uint8_t red, uint8_t green, uint8_t blue) { setMatrixPixel(matrixFrame, x, y, red, green, blue); } void drawMatrixText(uint8_t *frame, const char *text, int y, uint8_t red, uint8_t green, uint8_t blue) { constexpr int scale = 2; const int length = strlen(text); const int width = length * 5 * scale + (length - 1) * scale; int x = (MATRIX_SIZE - width) / 2; for (int characterIndex = 0; characterIndex < length; ++characterIndex) { for (uint8_t row = 0; row < 7; ++row) { const uint8_t bits = glyphRow(text[characterIndex], row); for (uint8_t column = 0; column < 5; ++column) { if ((bits & (1 << (4 - column))) == 0) continue; for (int dy = 0; dy < scale; ++dy) { for (int dx = 0; dx < scale; ++dx) { setMatrixPixel(frame, x + column * scale + dx, y + row * scale + dy, red, green, blue); } } } } x += 6 * scale; } } void renderSubmittedText(String text) { memset(matrixFrame, 0, sizeof(matrixFrame)); text.trim(); text.toUpperCase(); String lines[2]; uint8_t line = 0; for (size_t i = 0; i < text.length() && line < 2; ++i) { const char character = text[i]; if (character == '\n' || lines[line].length() == 5) { ++line; if (character == '\n') continue; } if (line < 2) lines[line] += character; } if (!lines[0].isEmpty()) drawMatrixText(matrixFrame, lines[0].c_str(), 9, 0, 210, 255); if (!lines[1].isEmpty()) drawMatrixText(matrixFrame, lines[1].c_str(), 39, 255, 90, 0); } void scanBluetoothDevices() { bluetoothBusy = true; bluetoothScanRequested = false; bluetoothStatus = "Scanning for BLE devices"; Serial.println("[bluetooth] scanning for 5 seconds"); NimBLEScan *scan = NimBLEDevice::getScan(); scan->clearResults(); scan->setActiveScan(false); scan->setInterval(100); scan->setWindow(30); NimBLEScanResults results = scan->getResults(BLUETOOTH_SCAN_SECONDS * 1000, false); bluetoothDeviceCount = 0; const int resultCount = results.getCount(); for (int i = 0; i < resultCount && bluetoothDeviceCount < MAX_BLUETOOTH_DEVICES; ++i) { const NimBLEAdvertisedDevice *advertised = results.getDevice(i); if (advertised == nullptr) continue; BluetoothDeviceInfo &device = bluetoothDevices[bluetoothDeviceCount++]; device.name = advertised->haveName() ? advertised->getName().c_str() : "Unnamed BLE device"; device.address = advertised->getAddress().toString().c_str(); device.rssi = advertised->getRSSI(); device.addressType = advertised->getAddressType(); device.isIdotMatrix = isIdotMatrixName(device.name) || advertised->isAdvertisingService(NimBLEUUID("000000fa-0000-1000-8000-00805f9b34fb")); Serial.printf("[bluetooth] %s address=%s rssi=%d connectable=%s%s\n", device.name.c_str(), device.address.c_str(), device.rssi, advertised->isConnectable() ? "yes" : "no", device.isIdotMatrix ? " [iDotMatrix]" : ""); } for (uint8_t i = 0; i < bluetoothDeviceCount; ++i) { for (uint8_t j = i + 1; j < bluetoothDeviceCount; ++j) { if (bluetoothDevices[j].rssi > bluetoothDevices[i].rssi) { BluetoothDeviceInfo temporary = bluetoothDevices[i]; bluetoothDevices[i] = bluetoothDevices[j]; bluetoothDevices[j] = temporary; } } } const int matrixIndex = findIdotMatrixDevice(); if (matrixIndex >= 0) { const BluetoothDeviceInfo &matrix = bluetoothDevices[matrixIndex]; if (matrixKnownAddress != matrix.address || matrixKnownAddressType != matrix.addressType) { matrixKnownAddress = matrix.address; matrixKnownAddressType = matrix.addressType; settingsPreferences.putString("matrix-address", matrixKnownAddress); settingsPreferences.putUChar("matrix-type", matrixKnownAddressType); Serial.printf("[bluetooth] remembered display address=%s type=%u\n", matrixKnownAddress.c_str(), matrixKnownAddressType); } } bluetoothStatus = "Scan complete: " + String(bluetoothDeviceCount) + " BLE devices found"; bluetoothBusy = false; } int findIdotMatrixDevice() { for (uint8_t i = 0; i < bluetoothDeviceCount; ++i) { if (bluetoothDevices[i].isIdotMatrix) return i; } return -1; } bool sendHelloWorldToIdotMatrix() { renderSubmittedText("HELLO WORLD"); return sendMatrixFrameToIdotMatrix(); } void releaseMatrixConnection() { if (matrixClient != nullptr) { if (matrixClient->isConnected()) matrixClient->disconnect(); NimBLEDevice::deleteClient(matrixClient); } matrixClient = nullptr; matrixWriteCharacteristic = nullptr; matrixNotifyCharacteristic = nullptr; matrixConnectedAddress = ""; } bool matrixConnectionIsReady() { return matrixClient != nullptr && matrixClient->isConnected() && matrixWriteCharacteristic != nullptr && matrixNotifyCharacteristic != nullptr; } void onMatrixNotification(NimBLERemoteCharacteristic *, uint8_t *data, size_t length, bool) { Serial.print("[bluetooth] FA03 notification:"); for (size_t i = 0; i < length; ++i) Serial.printf(" %02x", data[i]); Serial.println(); if (length >= 5 && data[0] == 5 && (data[2] == 1 || data[2] == 2)) { const uint8_t status = data[4]; matrixUploadStatus = status; if (matrixUploadStatusQueue != nullptr) { xQueueSend(matrixUploadStatusQueue, &status, 0); } } } bool ensureMatrixConnection() { if (matrixConnectionIsReady()) return true; matrixWriteCharacteristic = nullptr; matrixNotifyCharacteristic = nullptr; matrixConnectedAddress = ""; if (matrixClient != nullptr && matrixClient->isConnected()) matrixClient->disconnect(); const int targetIndex = findIdotMatrixDevice(); BluetoothDeviceInfo target; if (targetIndex >= 0) { target = bluetoothDevices[targetIndex]; } else if (!matrixKnownAddress.isEmpty()) { target.name = "Known iDotMatrix"; target.address = matrixKnownAddress; target.rssi = 0; target.addressType = matrixKnownAddressType; target.isIdotMatrix = true; } else { bluetoothStatus = "No iDotMatrix display found"; return false; } bluetoothStatus = "Connecting to " + target.name; matrixLastError = ""; NimBLEScan *scan = NimBLEDevice::getScan(); if (scan->isScanning()) scan->stop(); for (uint8_t wait = 0; scan->isScanning() && wait < 20; ++wait) delay(10); delay(300); NimBLEAddress address(std::string(target.address.c_str()), target.addressType); Serial.printf("[bluetooth] NimBLE connect: name='%s' address=%s rssi=%d dBm address_type=%u timeout=10s\n", target.name.c_str(), target.address.c_str(), target.rssi, target.addressType); if (matrixClient == nullptr) { matrixClient = NimBLEDevice::createClient(); matrixClient->setConnectTimeout(10000); matrixClient->setConnectRetries(3); } else { matrixClient->setPeerAddress(address); } const uint32_t connectStartedAt = millis(); if (!matrixClient->connect(address)) { const int error = matrixClient->getLastError(); matrixLastError = "NimBLE GATT connection failed (error " + String(error) + ")"; Serial.printf("[bluetooth] NimBLE connection failed: name='%s' address=%s error=%d elapsed=%lu ms\n", target.name.c_str(), target.address.c_str(), error, static_cast(millis() - connectStartedAt)); bluetoothStatus = "Connection failed: " + target.name + " — " + matrixLastError; return false; } NimBLERemoteService *service = matrixClient->getService(NimBLEUUID("000000fa-0000-1000-8000-00805f9b34fb")); matrixWriteCharacteristic = service == nullptr ? nullptr : service->getCharacteristic(NimBLEUUID("0000fa02-0000-1000-8000-00805f9b34fb")); matrixNotifyCharacteristic = service == nullptr ? nullptr : service->getCharacteristic(NimBLEUUID("0000fa03-0000-1000-8000-00805f9b34fb")); if (matrixWriteCharacteristic != nullptr && matrixWriteCharacteristic->canWrite() && matrixNotifyCharacteristic != nullptr && matrixNotifyCharacteristic->canNotify() && matrixNotifyCharacteristic->subscribe(true, onMatrixNotification)) { matrixClient->updateConnParams(24, 24, 0, 600); delay(150); matrixConnectedAddress = target.address; matrixKnownAddress = target.address; matrixKnownAddressType = target.addressType; matrixLastError = ""; bluetoothStatus = "Connected to " + target.name; Serial.printf("[bluetooth] NimBLE connected: name='%s' address=%s mtu=%u elapsed=%lu ms\n", target.name.c_str(), target.address.c_str(), matrixClient->getMTU(), static_cast(millis() - connectStartedAt)); return true; } matrixLastError = service == nullptr ? "Connected, but iDotMatrix GATT service FA00 was not found" : "Connected, but FA02 writes or FA03 notifications were unavailable"; releaseMatrixConnection(); bluetoothStatus = "Connection failed: " + target.name + " — " + matrixLastError; return false; } bool writeMatrixPacket(const uint8_t *data, size_t length, bool response) { constexpr size_t MAX_FRAGMENT_BYTES = 244; constexpr uint32_t FRAGMENT_DELAY_MS = 10; for (size_t offset = 0; offset < length; offset += MAX_FRAGMENT_BYTES) { const size_t fragmentLength = min(MAX_FRAGMENT_BYTES, length - offset); const bool fragmentResponse = response && offset + fragmentLength == length; if (!matrixWriteCharacteristic->writeValue(data + offset, fragmentLength, fragmentResponse)) { Serial.printf("[bluetooth] write failed at offset=%u length=%u response=%s connected=%s error=%d\n", static_cast(offset), static_cast(fragmentLength), fragmentResponse ? "yes" : "no", matrixClient != nullptr && matrixClient->isConnected() ? "yes" : "no", matrixClient == nullptr ? 0 : matrixClient->getLastError()); return false; } delay(FRAGMENT_DELAY_MS); } return true; } uint32_t frameCrc32(const uint8_t *data, size_t length) { uint32_t crc = 0xffffffff; for (size_t i = 0; i < length; ++i) { crc ^= data[i]; for (uint8_t bit = 0; bit < 8; ++bit) { crc = (crc >> 1) ^ (0xedb88320 & (0 - (crc & 1))); } } return crc ^ 0xffffffff; } void writeLittleEndian32(uint8_t *destination, uint32_t value) { destination[0] = value; destination[1] = value >> 8; destination[2] = value >> 16; destination[3] = value >> 24; } bool waitForMatrixUploadStatus(uint8_t expectedStatus, bool allowAlreadyComplete = false, bool *alreadyComplete = nullptr) { const uint32_t deadline = millis() + 5000; uint8_t receivedStatus = 0; while (static_cast(deadline - millis()) > 0) { const TickType_t waitTicks = pdMS_TO_TICKS(min(deadline - millis(), 250)); if (xQueueReceive(matrixUploadStatusQueue, &receivedStatus, waitTicks) != pdTRUE) continue; if (receivedStatus == expectedStatus) return true; if (allowAlreadyComplete && receivedStatus == 3) { if (alreadyComplete != nullptr) *alreadyComplete = true; Serial.println("[bluetooth] display reports that this frame is already complete"); return true; } Serial.printf("[bluetooth] ignored stale upload status=%u while waiting for=%u\n", receivedStatus, expectedStatus); } Serial.printf("[bluetooth] timed out waiting for upload status=%u\n", expectedStatus); return false; } // Newer 64x64 panels use the vendor app's raw RGB image transport: three // 4096-byte chunks, each with a 16-byte header and a CRC32 for the full frame. bool sendMatrixFrameToIdotMatrix() { bluetoothBusy = true; bool connected = matrixConnectionIsReady(); if (connected) Serial.println("[bluetooth] reusing retained display connection"); for (uint8_t attempt = 1; attempt <= 3 && !connected; ++attempt) { if (attempt > 1) { const uint32_t backoffMs = (attempt - 1) * 1000; Serial.printf("[bluetooth] waiting %lu ms before retry\n", static_cast(backoffMs)); delay(backoffMs); } if (attempt == 3 || matrixKnownAddress.isEmpty()) scanBluetoothDevices(); bluetoothBusy = true; Serial.printf("[bluetooth] exclusive connection attempt %u/3 (%s)\n", attempt, attempt < 3 && !matrixKnownAddress.isEmpty() ? "direct" : "fresh scan"); connected = ensureMatrixConnection(); } if (!connected) { bluetoothBusy = false; return false; } bluetoothStatus = "Uploading 64x64 frame"; bool success = true; const uint32_t crc = frameCrc32(matrixFrame, MATRIX_FRAME_BYTES); bool alreadyComplete = false; static uint8_t packet[MATRIX_CHUNK_BYTES + 16]; for (size_t offset = 0; success && offset < MATRIX_FRAME_BYTES; offset += MATRIX_CHUNK_BYTES) { const uint16_t packetLength = MATRIX_CHUNK_BYTES + 16; packet[0] = packetLength; packet[1] = packetLength >> 8; packet[2] = 2; packet[3] = 0; packet[4] = offset == 0 ? 0 : 2; writeLittleEndian32(packet + 5, MATRIX_FRAME_BYTES); writeLittleEndian32(packet + 9, crc); packet[13] = 0; packet[14] = 0; packet[15] = 12; memcpy(packet + 16, matrixFrame + offset, MATRIX_CHUNK_BYTES); matrixUploadStatus = 0; xQueueReset(matrixUploadStatusQueue); success = writeMatrixPacket(packet, sizeof(packet), true); if (success) { const bool finalChunk = offset + MATRIX_CHUNK_BYTES == MATRIX_FRAME_BYTES; success = waitForMatrixUploadStatus(finalChunk ? 3 : 1, offset == 0, &alreadyComplete); if (alreadyComplete) break; } } if (success) { memcpy(matrixSentFrame, matrixFrame, sizeof(matrixFrame)); matrixSentFrameKnown = true; matrixFrameKnown = true; lastMatrixRenderAt = millis(); bluetoothStatus = alreadyComplete ? "Frame already displayed; connection retained" : "Frame rendered; connection retained"; if (serverFramePending) { renderedFrameRevision = currentFrameRevision; renderedFrameCrc = crc; renderedFrameCrcKnown = true; settingsPreferences.putString("render-rev", renderedFrameRevision); settingsPreferences.putUInt("render-crc", renderedFrameCrc); serverFramePending = false; Serial.printf("[server] saved rendered revision %s crc=%08lx\n", renderedFrameRevision.c_str(), static_cast(renderedFrameCrc)); } pendingAcknowledgementRevision = renderedFrameRevision; Serial.printf("[bluetooth] 64x64 RGB frame %s\n", alreadyComplete ? "was already displayed" : "rendered"); } else { bluetoothStatus = "Grid transfer failed; reconnecting"; Serial.println("[bluetooth] grid transfer failed"); releaseMatrixConnection(); } bluetoothBusy = false; return success; } void pollFrameServer() { if (serverUrl.isEmpty() || WiFi.status() != WL_CONNECTED || bluetoothBusy || matrixRenderRequested) return; HTTPClient http; String endpoint = serverUrl; if (!endpoint.endsWith("/")) endpoint += "/"; endpoint += "api/pixel/device/frame"; if (!currentFrameRevision.isEmpty()) endpoint += "?since=" + currentFrameRevision; const char *headers[] = {"X-Frame-Revision"}; http.collectHeaders(headers, 1); http.setConnectTimeout(5000); http.setTimeout(15000); WiFiClient plainClient; WiFiClientSecure secureClient; bool started = false; if (endpoint.startsWith("https://")) { secureClient.setInsecure(); started = http.begin(secureClient, endpoint); } else { started = http.begin(plainClient, endpoint); } if (!started) { Serial.printf("[server] could not start request for %s\n", endpoint.c_str()); return; } if (serverCredentialsConfigured()) http.setAuthorization(serverUsername.c_str(), serverPassword.c_str()); const int status = http.GET(); if (status == HTTP_CODE_OK && http.getSize() == MATRIX_FRAME_BYTES) { WiFiClient *stream = http.getStreamPtr(); size_t received = 0; uint32_t lastDataAt = millis(); while (received < MATRIX_FRAME_BYTES && millis() - lastDataAt < 15000) { const int available = stream->available(); if (available <= 0) { delay(10); continue; } const size_t remaining = MATRIX_FRAME_BYTES - received; const size_t requested = min(static_cast(available), remaining); const int count = stream->read(matrixFrame + received, requested); if (count > 0) { received += count; lastDataAt = millis(); } } if (received == MATRIX_FRAME_BYTES) { const String downloadedRevision = http.header("X-Frame-Revision"); const uint32_t downloadedCrc = frameCrc32(matrixFrame, MATRIX_FRAME_BYTES); matrixFrameKnown = true; currentFrameRevision = downloadedRevision; if (renderedFrameCrcKnown && downloadedCrc == renderedFrameCrc) { renderedFrameRevision = downloadedRevision; settingsPreferences.putString("render-rev", renderedFrameRevision); serverFramePending = false; bluetoothStatus = "Server frame unchanged; no display update needed"; Serial.printf("[server] revision %s has unchanged pixels crc=%08lx; skipped BLE upload\n", currentFrameRevision.c_str(), static_cast(downloadedCrc)); } else { serverFramePending = true; matrixRenderRequested = true; bluetoothStatus = "New server frame queued"; Serial.printf("[server] downloaded changed frame revision %s crc=%08lx\n", currentFrameRevision.c_str(), static_cast(downloadedCrc)); } } else { Serial.printf("[server] incomplete frame body: received=%u expected=%u\n", static_cast(received), static_cast(MATRIX_FRAME_BYTES)); } } else if (status != HTTP_CODE_NO_CONTENT) { Serial.printf("[server] poll failed: HTTP %d\n", status); } http.end(); } void onFrameWebSocketEvent(WStype_t type, uint8_t *payload, size_t length) { switch (type) { case WStype_CONNECTED: frameWebSocketConnected = true; frameWebSocketUnavailableSince = 0; lastServerPollAt = millis(); Serial.println("[server] WebSocket connected; awaiting current frame"); break; case WStype_DISCONNECTED: if (frameWebSocketConnected) Serial.println("[server] WebSocket disconnected; polling fallback active"); frameWebSocketConnected = false; announcedFrameRevision = ""; receivedWebSocketFrameBytes = 0; if (frameWebSocketUnavailableSince == 0) frameWebSocketUnavailableSince = millis(); break; case WStype_TEXT: { const String revision(reinterpret_cast(payload), length); if (revision != "connected") { announcedFrameRevision = revision; receivedWebSocketFrameBytes = 0; Serial.printf("[server] WebSocket announced revision %s\n", revision.c_str()); } break; } case WStype_BIN: { const size_t remaining = MATRIX_FRAME_BYTES - receivedWebSocketFrameBytes; if (!announcedFrameRevision.isEmpty() && length == MATRIX_WEBSOCKET_CHUNK_BYTES && length <= remaining) { memcpy(matrixFrame + receivedWebSocketFrameBytes, payload, length); receivedWebSocketFrameBytes += length; Serial.printf("[server] WebSocket frame chunk received=%u/%u\n", static_cast(receivedWebSocketFrameBytes), static_cast(MATRIX_FRAME_BYTES)); } else { Serial.printf("[server] rejected WebSocket binary chunk length=%u received=%u\n", static_cast(length), static_cast(receivedWebSocketFrameBytes)); announcedFrameRevision = ""; receivedWebSocketFrameBytes = 0; break; } if (receivedWebSocketFrameBytes == MATRIX_FRAME_BYTES) { matrixFrameKnown = true; currentFrameRevision = announcedFrameRevision; announcedFrameRevision = ""; receivedWebSocketFrameBytes = 0; const uint32_t receivedCrc = frameCrc32(matrixFrame, MATRIX_FRAME_BYTES); if (renderedFrameCrcKnown && receivedCrc == renderedFrameCrc) { renderedFrameRevision = currentFrameRevision; settingsPreferences.putString("render-rev", renderedFrameRevision); serverFramePending = false; pendingAcknowledgementRevision = renderedFrameRevision; Serial.printf("[server] WebSocket frame unchanged crc=%08lx; watchdog will maintain display\n", static_cast(receivedCrc)); } else { serverFramePending = true; matrixRenderRequested = true; bluetoothStatus = "WebSocket frame queued"; Serial.printf("[server] WebSocket received changed frame crc=%08lx\n", static_cast(receivedCrc)); } } break; } case WStype_ERROR: Serial.println("[server] WebSocket error; polling fallback remains active"); break; default: break; } } void configureFrameWebSocket() { if (frameWebSocketConfigured) frameWebSocket.disconnect(); frameWebSocketConfigured = false; frameWebSocketConnected = false; frameWebSocketUnavailableSince = millis(); const bool secure = serverUrl.startsWith("https://"); const bool plain = serverUrl.startsWith("http://"); if (!secure && !plain) return; const size_t authorityStart = secure ? 8 : 7; const int pathStart = serverUrl.indexOf('/', authorityStart); String authority = pathStart < 0 ? serverUrl.substring(authorityStart) : serverUrl.substring(authorityStart, pathStart); String host = authority; uint16_t port = secure ? 443 : 80; const int portSeparator = authority.lastIndexOf(':'); if (portSeparator > 0) { host = authority.substring(0, portSeparator); port = authority.substring(portSeparator + 1).toInt(); } if (host.isEmpty() || port == 0) return; frameWebSocket.onEvent(onFrameWebSocketEvent); frameWebSocket.setReconnectInterval(3000); frameWebSocket.enableHeartbeat(15000, 3000, 2); frameWebSocketHeaders = serverCredentialsConfigured() ? "Authorization: Basic " + base64Encode(serverUsername + ":" + serverPassword) : ""; frameWebSocket.setExtraHeaders(frameWebSocketHeaders.isEmpty() ? nullptr : frameWebSocketHeaders.c_str()); if (secure) { frameWebSocket.beginSSL(host, port, "/api/pixel/device/updates"); } else { frameWebSocket.begin(host, port, "/api/pixel/device/updates"); } frameWebSocketConfigured = true; Serial.printf("[server] WebSocket configured for %s:%u\n", host.c_str(), port); } String bluetoothJson() { String json = "{\"busy\":" + String(bluetoothBusy ? "true" : "false"); json += ",\"connected\":" + String(matrixConnectionIsReady() ? "true" : "false"); json += ",\"connectedAddress\":\"" + jsonEscape(matrixConnectedAddress) + "\""; json += ",\"lastError\":\"" + jsonEscape(matrixLastError) + "\""; json += ",\"status\":\"" + jsonEscape(bluetoothStatus) + "\",\"devices\":["; for (uint8_t i = 0; i < bluetoothDeviceCount; ++i) { if (i) json += ','; const BluetoothDeviceInfo &device = bluetoothDevices[i]; json += "{\"name\":\"" + jsonEscape(device.name) + "\",\"address\":\"" + jsonEscape(device.address) + "\""; json += ",\"rssi\":" + String(device.rssi) + ",\"idot\":" + String(device.isIdotMatrix ? "true" : "false") + "}"; } json += "]}"; return json; } void loadCredentials() { credentialCount = min(preferences.getUChar("count", 0), MAX_CREDENTIALS); uint8_t validCount = 0; for (uint8_t i = 0; i < credentialCount; ++i) { String ssid = preferences.getString(("ssid" + String(i)).c_str(), ""); if (ssid.isEmpty()) continue; credentials[validCount].ssid = ssid; credentials[validCount].password = preferences.getString(("pass" + String(i)).c_str(), ""); ++validCount; } credentialCount = validCount; } void persistCredentials() { preferences.clear(); preferences.putUChar("count", credentialCount); for (uint8_t i = 0; i < credentialCount; ++i) { preferences.putString(("ssid" + String(i)).c_str(), credentials[i].ssid); preferences.putString(("pass" + String(i)).c_str(), credentials[i].password); } } int findCredential(const String &ssid) { for (uint8_t i = 0; i < credentialCount; ++i) { if (credentials[i].ssid == ssid) return i; } return -1; } void startPortal() { if (portalRunning) return; WiFi.mode(WIFI_AP_STA); if (!WiFi.softAP(portalSsid.c_str(), AP_PASSWORD, AP_CHANNEL, false, AP_MAX_CLIENTS)) { Serial.println("Failed to start setup access point"); return; } dnsServer.start(DNS_PORT, "*", WiFi.softAPIP()); portalRunning = true; Serial.printf("Setup portal: http://%s (AP: %s, password: %s)\n", WiFi.softAPIP().toString().c_str(), portalSsid.c_str(), AP_PASSWORD); } String statusJson() { const bool connected = WiFi.status() == WL_CONNECTED; String json = "{\"connected\":" + String(connected ? "true" : "false"); json += ",\"ssid\":\"" + jsonEscape(connected ? WiFi.SSID() : "") + "\""; json += ",\"ip\":\"" + String(connected ? WiFi.localIP().toString() : WiFi.softAPIP().toString()) + "\""; json += ",\"portalSsid\":\"" + jsonEscape(portalSsid) + "\""; json += ",\"portalPassword\":\"" + String(AP_PASSWORD) + "\""; json += ",\"serverUrl\":\"" + jsonEscape(serverUrl) + "\""; json += ",\"serverCredentialsConfigured\":" + String(serverCredentialsConfigured() ? "true" : "false"); json += ",\"frameRevision\":\"" + jsonEscape(currentFrameRevision) + "\""; json += ",\"savedCount\":" + String(credentialCount) + "}"; return json; } const char PAGE[] PROGMEM = R"HTML( ESP32 Wi-Fi and Bluetooth setup

ESP32 Wi-Fi setup

Loading status…

Available networks

Add or update a network

Saved networks

PixelSync server

The ESP32 receives realtime frame updates from PixelSync, with automatic polling fallback.

Nearby Bluetooth devices

The ESP32 uses NimBLE and retains a successful display connection for reliable future updates.

Pixel display text

Up to 10 letters, numbers, and spaces. Five characters are shown per line.

)HTML"; void sendPage() { server.sendHeader("Cache-Control", "no-store"); server.send_P(200, "text/html", PAGE); } void sendNetworks() { const bool forceRescan = server.hasArg("rescan"); int count = WiFi.scanComplete(); if (forceRescan || count == WIFI_SCAN_FAILED) { WiFi.scanDelete(); count = WiFi.scanNetworks(false, true, false, 300); } else if (count == WIFI_SCAN_RUNNING) { server.send(202, "application/json", "{\"networks\":[],\"saved\":[]}"); return; } String json = "{\"networks\":["; bool first = true; String emitted[MAX_CREDENTIALS * 2]; uint8_t emittedCount = 0; for (int i = 0; i < count; ++i) { const String ssid = WiFi.SSID(i); if (ssid.isEmpty()) continue; bool duplicate = false; for (uint8_t j = 0; j < emittedCount; ++j) duplicate |= emitted[j] == ssid; if (duplicate) continue; if (emittedCount < MAX_CREDENTIALS * 2) emitted[emittedCount++] = ssid; if (!first) json += ','; first = false; json += "{\"ssid\":\"" + jsonEscape(ssid) + "\",\"rssi\":" + String(WiFi.RSSI(i)); json += ",\"secure\":" + String(WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "false" : "true"); json += ",\"saved\":" + String(findCredential(ssid) >= 0 ? "true" : "false") + "}"; } json += "],\"saved\":["; for (uint8_t i = 0; i < credentialCount; ++i) { if (i) json += ','; json += "\"" + jsonEscape(credentials[i].ssid) + "\""; } json += "]}"; server.sendHeader("Cache-Control", "no-store"); server.send(200, "application/json", json); } void configureServer() { const char *matrixHeaders[] = {"X-Offset"}; server.collectHeaders(matrixHeaders, 1); server.on("/", HTTP_GET, sendPage); server.on("/api/status", HTTP_GET, [] { server.send(200, "application/json", statusJson()); }); server.on("/api/networks", HTTP_GET, sendNetworks); server.on("/server", HTTP_POST, [] { String url = server.arg("url"); url.trim(); while (url.endsWith("/")) url.remove(url.length() - 1); if (!(url.startsWith("http://") || url.startsWith("https://")) || url.length() > 180) { server.send(400, "text/plain", "Enter a valid http:// or https:// server URL."); return; } serverUrl = url; serverUsername = server.arg("username"); serverPassword = server.arg("password"); currentFrameRevision = ""; renderedFrameRevision = ""; renderedFrameCrc = 0; renderedFrameCrcKnown = false; serverFramePending = false; settingsPreferences.putString("server-url", serverUrl); settingsPreferences.putString("server-user", serverUsername); settingsPreferences.putString("server-pass", serverPassword); settingsPreferences.remove("render-rev"); settingsPreferences.remove("render-crc"); lastServerPollAt = 0; configureFrameWebSocket(); server.send(200, "text/plain", "Saved. The first frame will sync shortly."); }); server.on("/api/bluetooth", HTTP_GET, [] { if (server.hasArg("rescan") && !bluetoothBusy) { bluetoothScanRequested = true; bluetoothStatus = "Bluetooth scan queued"; } server.sendHeader("Cache-Control", "no-store"); server.send(200, "application/json", bluetoothJson()); }); server.on("/bluetooth/connect", HTTP_POST, [] { if (bluetoothBusy) { server.send(409, "text/plain", "Bluetooth is busy. Try again shortly."); return; } if (matrixConnectionIsReady()) { server.send(200, "text/plain", "iDotMatrix is already connected."); return; } matrixConnectionRequested = true; // A fresh advertisement avoids attempting a connection using a stale BLE // address or address type from a prior scan. bluetoothScanRequested = true; matrixReconnectScanPending = false; bluetoothStatus = "Scanning for iDotMatrix before connecting"; server.send(202, "text/plain", "iDotMatrix connection queued."); }); server.on("/matrix/text", HTTP_POST, [] { String text = server.arg("text"); text.trim(); if (text.isEmpty() || text.length() > 10) { server.send(400, "text/plain", "Enter 1 to 10 characters."); return; } if (bluetoothBusy || matrixRenderRequested) { server.send(409, "text/plain", "Display is busy. Try again shortly."); return; } renderSubmittedText(text); matrixRenderRequested = true; bluetoothStatus = "Text render queued"; server.send(202, "text/plain", "Text render queued."); }); server.on("/api/matrix", HTTP_POST, [] { const String payload = server.arg("plain"); if (!server.hasHeader("X-Offset")) { server.send(400, "text/plain", "Missing X-Offset header."); return; } const size_t offset = static_cast(server.header("X-Offset").toInt()); if (offset % MATRIX_HTTP_CHUNK_BYTES != 0 || offset + MATRIX_HTTP_CHUNK_BYTES > MATRIX_FRAME_BYTES) { server.send(400, "text/plain", "Invalid X-Offset."); return; } if (bluetoothBusy || matrixRenderRequested) { server.send(409, "text/plain", "Display is busy. Try again shortly."); return; } if (offset == 0) memset(matrixChunkReceived, 0, sizeof(matrixChunkReceived)); uint8_t decoded[MATRIX_HTTP_CHUNK_BYTES]; if (!decodeMatrixChunk(payload, decoded)) { server.send(400, "text/plain", "Expected a 1024-character Base64 RGB chunk."); return; } memcpy(matrixFrame + offset, decoded, sizeof(decoded)); matrixChunkReceived[offset / MATRIX_HTTP_CHUNK_BYTES] = true; bool complete = true; for (size_t i = 0; i < MATRIX_HTTP_CHUNK_COUNT; ++i) complete &= matrixChunkReceived[i]; if (complete) { matrixRenderRequested = true; bluetoothStatus = "Grid render queued"; server.send(202, "text/plain", "Grid render queued."); } else { server.send(200, "text/plain", "Grid piece stored."); } }); server.on("/bluetooth/hello", HTTP_POST, [] { if (bluetoothBusy) { server.send(409, "text/plain", "Bluetooth is busy. Try again shortly."); return; } helloWorldRequested = true; if (findIdotMatrixDevice() < 0) bluetoothScanRequested = true; bluetoothStatus = "Hello World queued"; server.send(202, "text/plain", "Hello World queued for the iDotMatrix display."); }); server.on("/save", HTTP_POST, [] { String ssid = server.arg("ssid"); String password = server.arg("password"); ssid.trim(); if (ssid.isEmpty() || ssid.length() > 32 || password.length() > 63) { server.send(400, "text/plain", "Invalid SSID or password length."); return; } int index = findCredential(ssid); if (index < 0) { if (credentialCount >= MAX_CREDENTIALS) { server.send(409, "text/plain", "Storage is full. Forget a saved network first."); return; } index = credentialCount++; } credentials[index].ssid = ssid; credentials[index].password = password; persistCredentials(); connectionCycleRequested = true; server.send(200, "text/plain", "Saved. Trying this network now…"); }); server.on("/delete", HTTP_POST, [] { const int index = findCredential(server.arg("ssid")); if (index < 0) { server.send(404, "text/plain", "Saved network not found."); return; } for (uint8_t i = index; i + 1 < credentialCount; ++i) credentials[i] = credentials[i + 1]; --credentialCount; persistCredentials(); connectionCycleRequested = true; server.send(200, "text/plain", "Network forgotten."); }); server.on("/generate_204", HTTP_ANY, sendPage); server.on("/hotspot-detect.html", HTTP_ANY, sendPage); server.on("/connecttest.txt", HTTP_ANY, sendPage); server.on("/ncsi.txt", HTTP_ANY, sendPage); server.onNotFound(sendPage); server.begin(); } void startNextCandidate() { if (candidatePosition >= candidateCount) { attemptInProgress = false; lastCycleFinishedAt = millis(); Serial.println("No saved network could be reached; setup AP remains active"); return; } const Credential &credential = credentials[candidates[candidatePosition++].credentialIndex]; // Prevent the Wi-Fi driver from racing its automatic reconnect against the // explicit candidate we are about to select. WiFi.setAutoReconnect(false); WiFi.disconnect(false, false); delay(800); Serial.printf("Trying saved network: %s\n", credential.ssid.c_str()); WiFi.begin(credential.ssid.c_str(), credential.password.c_str()); WiFi.setAutoReconnect(true); attemptStartedAt = millis(); attemptInProgress = true; } void prepareConnectionCycle() { connectionCycleRequested = false; attemptInProgress = false; candidateCount = 0; candidatePosition = 0; if (credentialCount == 0) { lastCycleFinishedAt = millis(); return; } WiFi.scanDelete(); const int networkCount = WiFi.scanNetworks(false, true, false, 250); bool added[MAX_CREDENTIALS] = {}; for (int i = 0; i < networkCount; ++i) { const int credentialIndex = findCredential(WiFi.SSID(i)); if (credentialIndex < 0 || added[credentialIndex]) continue; candidates[candidateCount++] = {static_cast(credentialIndex), WiFi.RSSI(i)}; added[credentialIndex] = true; } for (uint8_t i = 0; i < credentialCount; ++i) { if (!added[i]) candidates[candidateCount++] = {i, -1000}; } for (uint8_t i = 0; i < candidateCount; ++i) { for (uint8_t j = i + 1; j < candidateCount; ++j) { if (candidates[j].rssi > candidates[i].rssi) { Candidate temporary = candidates[i]; candidates[i] = candidates[j]; candidates[j] = temporary; } } } startNextCandidate(); } } // namespace void setup() { Serial.begin(115200); delay(300); Serial.println("\nESP32 multi-network Wi-Fi manager starting"); pinMode(STATUS_LED_PIN, OUTPUT); digitalWrite(STATUS_LED_PIN, LED_OFF); previousConnectionState = WiFi.status() == WL_CONNECTED; lastLedToggleAt = millis(); preferences.begin("wifi-manager", false); settingsPreferences.begin("pixel-settings", false); loadCredentials(); serverUrl = settingsPreferences.getString("server-url", DEFAULT_SERVER_URL); serverUsername = settingsPreferences.getString("server-user", ""); serverPassword = settingsPreferences.getString("server-pass", ""); if (serverUrl == LEGACY_RAILWAY_SERVER_URL || serverUrl == LEGACY_VERCEL_SERVER_URL) { serverUrl = DEFAULT_SERVER_URL; settingsPreferences.putString("server-url", serverUrl); Serial.printf("[server] migrated server URL to %s\n", serverUrl.c_str()); } renderedFrameRevision = settingsPreferences.getString("render-rev", ""); renderedFrameCrcKnown = settingsPreferences.isKey("render-crc"); renderedFrameCrc = settingsPreferences.getUInt("render-crc", 0); // Always download frame bytes after boot. The revision and CRC survive a // restart, but the 12 KB RGB buffer is RAM-only and is needed by watchdog refreshes. currentFrameRevision = ""; matrixKnownAddress = settingsPreferences.getString("matrix-address", ""); matrixKnownAddressType = settingsPreferences.getUChar("matrix-type", 0); matrixUploadStatusQueue = xQueueCreate(8, sizeof(uint8_t)); if (matrixUploadStatusQueue == nullptr) { Serial.println("Failed to allocate matrix upload status queue"); ESP.restart(); } if (!renderedFrameRevision.isEmpty()) { Serial.printf("[server] loaded rendered revision %s crc=%08lx\n", renderedFrameRevision.c_str(), static_cast(renderedFrameCrc)); } const uint64_t chipId = ESP.getEfuseMac(); char suffix[7]; snprintf(suffix, sizeof(suffix), "%06llX", chipId & 0xFFFFFFULL); portalSsid = "OL-ESP32-AP-" + String(suffix); WiFi.persistent(false); WiFi.setAutoReconnect(true); WiFi.setSleep(true); #if CONFIG_IDF_TARGET_ESP32C3 // Keep this C3 distinct from the classic ESP32 bridge on the same LAN. // The router has handed its prior DHCP address to another device, making // the C3's web page unreachable despite a successful Wi-Fi association. WiFi.setHostname("ol-esp32-c3"); #else WiFi.setHostname("ol-esp32-bridge"); #endif startPortal(); configureServer(); configureFrameWebSocket(); NimBLEDevice::init("OL-ESP32-Bridge"); NimBLEDevice::setMTU(247); NimBLEDevice::setPower(9); if (!matrixKnownAddress.isEmpty()) { bluetoothScanRequested = false; bluetoothStatus = "Known iDotMatrix ready for direct connection"; Serial.printf("[bluetooth] loaded display address=%s type=%u\n", matrixKnownAddress.c_str(), matrixKnownAddressType); } Serial.println("NimBLE scanner initialized with MTU 247"); } void loop() { updateStatusLed(); server.handleClient(); if (portalRunning) dnsServer.processNextRequest(); if (frameWebSocketConfigured && WiFi.status() == WL_CONNECTED && !bluetoothBusy) { frameWebSocket.loop(); } if (frameWebSocketConnected && !pendingAcknowledgementRevision.isEmpty()) { String acknowledgement = "{\"appliedRevision\":\"" + jsonEscape(pendingAcknowledgementRevision) + "\",\"bluetoothConnected\":" + String(matrixConnectionIsReady() ? "true" : "false") + "}"; if (frameWebSocket.sendTXT(acknowledgement)) { Serial.printf("[server] acknowledged unchanged revision %s\n", pendingAcknowledgementRevision.c_str()); pendingAcknowledgementRevision = ""; } } if (!bluetoothBusy && bluetoothScanRequested && millis() >= BLUETOOTH_START_DELAY_MS) { scanBluetoothDevices(); } if (!bluetoothBusy && matrixConnectionRequested && !bluetoothScanRequested) { matrixConnectionRequested = false; lastMatrixConnectionAttemptAt = millis(); bluetoothBusy = true; ensureMatrixConnection(); if (matrixConnectionIsReady()) { bluetoothStatus = "Connection test succeeded; disconnected"; releaseMatrixConnection(); } bluetoothBusy = false; } if (!bluetoothBusy && helloWorldRequested && !bluetoothScanRequested) { helloWorldRequested = false; startDisplaySyncIndicator(); sendHelloWorldToIdotMatrix(); stopDisplaySyncIndicator(); } if (matrixFrameKnown && !bluetoothBusy && !matrixRenderRequested && (lastMatrixRenderAt == 0 || millis() - lastMatrixRenderAt >= MATRIX_REFRESH_INTERVAL_MS)) { matrixRenderRequested = true; serverFramePending = false; bluetoothStatus = "Display watchdog refresh queued"; Serial.println("[bluetooth] watchdog queued retained frame refresh"); } if (matrixRenderRequested) startDisplaySyncIndicator(); if (!bluetoothBusy && matrixRenderRequested && !bluetoothScanRequested && (lastMatrixConnectionAttemptAt == 0 || millis() - lastMatrixConnectionAttemptAt >= matrixReconnectDelayMs)) { lastMatrixConnectionAttemptAt = millis(); matrixRenderRequested = false; if (!sendMatrixFrameToIdotMatrix()) { matrixRenderRequested = true; bluetoothScanRequested = true; bluetoothStatus = "Display unavailable; retrying automatically"; } else { lastMatrixConnectionAttemptAt = 0; stopDisplaySyncIndicator(); } } const bool serverPollingFallbackReady = !frameWebSocketConfigured || (!frameWebSocketConnected && frameWebSocketUnavailableSince != 0 && millis() - frameWebSocketUnavailableSince >= SERVER_WEBSOCKET_FALLBACK_DELAY_MS); if (serverPollingFallbackReady && WiFi.status() == WL_CONNECTED && !bluetoothBusy && (lastServerPollAt == 0 || millis() - lastServerPollAt >= SERVER_DISCONNECTED_POLL_INTERVAL_MS)) { lastServerPollAt = millis(); pollFrameServer(); } if (millis() - lastDiagnosticAt >= DIAGNOSTIC_INTERVAL_MS) { lastDiagnosticAt = millis(); Serial.printf("[wifi] station=%s station_ssid=%s station_ip=%s ap=%s ap_ssid=%s ap_ip=%s channel=%ld clients=%u\n", WiFi.status() == WL_CONNECTED ? "connected" : "disconnected", WiFi.status() == WL_CONNECTED ? WiFi.SSID().c_str() : "-", WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString().c_str() : "-", portalRunning ? "running" : "stopped", portalSsid.c_str(), WiFi.softAPIP().toString().c_str(), static_cast(WiFi.channel()), static_cast(WiFi.softAPgetStationNum())); } if (WiFi.status() == WL_CONNECTED) { if (connectedAt == 0) { connectedAt = millis(); attemptInProgress = false; Serial.printf("Connected to %s; IP address: %s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); } delay(2); return; } connectedAt = 0; startPortal(); if (connectionCycleRequested) { prepareConnectionCycle(); } else if (attemptInProgress && millis() - attemptStartedAt >= CONNECT_TIMEOUT_MS) { startNextCandidate(); } else if (!attemptInProgress && credentialCount > 0 && millis() - lastCycleFinishedAt >= RETRY_INTERVAL_MS) { prepareConnectionCycle(); } delay(2); }