From 2d74892ddfe141e2fca3c8ba44fef316ff33a9c3 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 4 Nov 2026 15:19:13 -0600 Subject: [PATCH] feat(ui): show the BLE connect link as a QR code Adds a "Pair" frame, shown while Bluetooth is enabled, with a centered QR code of meshtastic://meshtastic/connections?address=x[&pin=]. The PIN is included in FIXED_PIN and RANDOM_PIN modes: the random PIN is picked once when Bluetooth starts, so it is known before pairing begins. NRF52Bluetooth and NimbleBluetooth gain getPasskey() to report it. The PIN is not printed on the frame. The encoder (src/graphics/QrEncode.cpp) is a cut-down ricmoo/QRCode: ECC level M, versions 1-6, byte mode, with function modules computed instead of tracked in a second grid. About 2.7 KB of flash and ~200 B of stack while encoding. The 73-character link encodes as version 5 (37x37), which fits a 128x64 panel at one pixel per module. test_qr_encode pins the link's symbol against Project Nayuki's encoder and checks version selection at each capacity boundary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011C7D9bSthe6aUpG3jEHLyt --- src/graphics/QrEncode.cpp | 343 ++++++++++++++++++++++++++ src/graphics/QrEncode.h | 22 ++ src/graphics/Screen.cpp | 8 + src/graphics/draw/DebugRenderer.cpp | 80 ++++++ src/graphics/draw/DebugRenderer.h | 2 + src/graphics/images.h | 3 + src/nimble/NimbleBluetooth.cpp | 16 +- src/nimble/NimbleBluetooth.h | 2 + src/platform/nrf52/NRF52Bluetooth.cpp | 12 + src/platform/nrf52/NRF52Bluetooth.h | 2 + test/test_qr_encode/test_main.cpp | 106 ++++++++ 11 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 src/graphics/QrEncode.cpp create mode 100644 src/graphics/QrEncode.h create mode 100644 test/test_qr_encode/test_main.cpp diff --git a/src/graphics/QrEncode.cpp b/src/graphics/QrEncode.cpp new file mode 100644 index 0000000000..a367f9c252 --- /dev/null +++ b/src/graphics/QrEncode.cpp @@ -0,0 +1,343 @@ +// Stripped-down fork of ricmoo/QRCode (https://github.com/ricmoo/QRCode), itself based on +// Project Nayuki's QR Code generator (https://www.nayuki.io/page/qr-code-generator-library). +// +// The MIT License (MIT) +// Copyright (c) 2017 Richard Moore +// Copyright (c) Project Nayuki +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +// Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +// WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "QrEncode.h" + +#include + +// ECC level M, versions 1-6 (index = version - 1). Every block in these versions has the same length. +static const uint8_t TOTAL_CODEWORDS[6] = {26, 44, 70, 100, 134, 172}; +static const uint8_t ECC_PER_BLOCK[6] = {10, 16, 26, 18, 24, 16}; +static const uint8_t NUM_BLOCKS[6] = {1, 1, 1, 2, 2, 4}; + +// 15-bit format words for ECC level M with masks 0-7 +static const uint16_t FORMAT_BITS[8] = {0x5412, 0x5125, 0x5E7C, 0x5B4B, 0x45F9, 0x40CE, 0x4F97, 0x4AA0}; + +#define MAX_CODEWORDS 172 +#define MAX_ECC_PER_BLOCK 26 + +static bool getBit(const uint8_t *m, uint8_t size, uint8_t x, uint8_t y) +{ + uint16_t i = y * size + x; + return (m[i >> 3] >> (7 - (i & 7))) & 1; +} + +static void setBit(uint8_t *m, uint8_t size, uint8_t x, uint8_t y, bool on) +{ + uint16_t i = y * size + x; + uint8_t b = 0x80 >> (i & 7); + if (on) + m[i >> 3] |= b; + else + m[i >> 3] &= ~b; +} + +// Computed rather than tracked in a second grid; versions 1-6 have no version-info area and one alignment pattern. +static bool isFunction(uint8_t size, uint8_t x, uint8_t y) +{ + if (x == 6 || y == 6) + return true; + if (x < 9 && (y < 9 || y >= size - 8)) + return true; + if (y < 9 && x >= size - 8) + return true; + return size > 21 && (uint8_t)(x - (size - 9)) < 5 && (uint8_t)(y - (size - 9)) < 5; +} + +static void drawFormatBits(uint8_t *m, uint8_t size, uint8_t mask) +{ + uint16_t bits = FORMAT_BITS[mask]; + for (uint8_t i = 0; i < 15; i++) { + bool on = (bits >> i) & 1; + if (i < 6) + setBit(m, size, 8, i, on); + else if (i < 8) + setBit(m, size, 8, i + 1, on); + else if (i == 8) + setBit(m, size, 7, 8, on); + else + setBit(m, size, 14 - i, 8, on); + + if (i < 8) + setBit(m, size, size - 1 - i, 8, on); + else + setBit(m, size, 8, size - 15 + i, on); + } + setBit(m, size, 8, size - 8, true); // Dark module +} + +static void drawFunctionPatterns(uint8_t *m, uint8_t size) +{ + for (uint8_t i = 8; i < size - 8; i += 2) { + setBit(m, size, 6, i, true); + setBit(m, size, i, 6, true); + } + + // Symmetric row bitmaps; separators are already light + static const uint8_t FINDER[7] = {0x7F, 0x41, 0x5D, 0x5D, 0x5D, 0x41, 0x7F}; + static const uint8_t ALIGN[5] = {0x1F, 0x11, 0x15, 0x11, 0x1F}; + uint8_t edge = size - 7; + for (uint8_t y = 0; y < 7; y++) { + for (uint8_t x = 0; x < 7; x++) { + if ((FINDER[y] >> x) & 1) { + setBit(m, size, x, y, true); + setBit(m, size, edge + x, y, true); + setBit(m, size, x, edge + y, true); + } + if (size > 21 && x < 5 && y < 5 && ((ALIGN[y] >> x) & 1)) + setBit(m, size, size - 9 + x, size - 9 + y, true); + } + } +} + +static void applyMask(uint8_t *m, uint8_t size, uint8_t mask) +{ + for (uint8_t y = 0; y < size; y++) { + for (uint8_t x = 0; x < size; x++) { + if (isFunction(size, x, y)) + continue; + bool invert; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (x / 3 + y / 2) % 2 == 0; + break; + case 5: + invert = x * y % 2 + x * y % 3 == 0; + break; + case 6: + invert = (x * y % 2 + x * y % 3) % 2 == 0; + break; + default: + invert = ((x + y) % 2 + x * y % 3) % 2 == 0; + break; + } + if (invert) { + uint16_t i = y * size + x; + m[i >> 3] ^= 0x80 >> (i & 7); + } + } + } +} + +static uint32_t getPenaltyScore(const uint8_t *m, uint8_t size) +{ + uint32_t result = 0; + uint16_t dark = 0; + + for (uint8_t y = 0; y < size; y++) { + uint8_t runRow = 0, runCol = 0; + bool lastRow = false, lastCol = false; + uint16_t bitsRow = 0, bitsCol = 0; + + for (uint8_t x = 0; x < size; x++) { + bool cRow = getBit(m, size, x, y); + bool cCol = getBit(m, size, y, x); + + if (x == 0 || cRow != lastRow) { + lastRow = cRow; + runRow = 1; + } else if (++runRow == 5) { + result += 3; + } else if (runRow > 5) { + result++; + } + if (x == 0 || cCol != lastCol) { + lastCol = cCol; + runCol = 1; + } else if (++runCol == 5) { + result += 3; + } else if (runCol > 5) { + result++; + } + + if (x > 0 && y > 0 && cRow == getBit(m, size, x - 1, y - 1) && cRow == getBit(m, size, x, y - 1) && + cRow == getBit(m, size, x - 1, y)) + result += 3; + + // Finder-like 1:1:3:1:1 runs + bitsRow = ((bitsRow << 1) & 0x7FF) | cRow; + bitsCol = ((bitsCol << 1) & 0x7FF) | cCol; + if (x >= 10) { + if (bitsRow == 0x05D || bitsRow == 0x5D0) + result += 40; + if (bitsCol == 0x05D || bitsCol == 0x5D0) + result += 40; + } + + dark += cRow; + } + } + + uint16_t total = size * size; + for (uint16_t k = 0; dark * 20 < (9 - k) * total || dark * 20 > (11 + k) * total; k++) + result += 10; + return result; +} + +// Reed-Solomon over GF(2^8 / 0x11D) +static uint8_t rsMultiply(uint8_t x, uint8_t y) +{ + uint16_t z = 0; + for (int8_t i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >> 7) * 0x11D); + z ^= ((y >> i) & 1) * x; + } + return z; +} + +static void rsInit(uint8_t degree, uint8_t *coeff) +{ + memset(coeff, 0, degree); + coeff[degree - 1] = 1; + uint8_t root = 1; + for (uint8_t i = 0; i < degree; i++) { + for (uint8_t j = 0; j < degree; j++) { + coeff[j] = rsMultiply(coeff[j], root); + if (j + 1 < degree) + coeff[j] ^= coeff[j + 1]; + } + root = rsMultiply(root, 0x02); + } +} + +// result must be zeroed +static void rsRemainder(uint8_t degree, const uint8_t *coeff, const uint8_t *data, uint8_t length, uint8_t *result) +{ + for (uint8_t i = 0; i < length; i++) { + uint8_t factor = data[i] ^ result[0]; + memmove(result, result + 1, degree - 1); + result[degree - 1] = 0; + for (uint8_t j = 0; j < degree; j++) + result[j] ^= rsMultiply(coeff[j], factor); + } +} + +static void appendBits(uint8_t *buf, uint16_t *offset, uint8_t val, uint8_t length) +{ + for (int8_t i = length - 1; i >= 0; i--, (*offset)++) + buf[*offset >> 3] |= ((val >> i) & 1) << (7 - (*offset & 7)); +} + +bool qrEncodeText(QrEncode *qr, uint8_t *modules, const char *text) +{ + size_t length = strlen(text); + uint8_t v = 1; + // 4-bit mode + 8-bit count + data bytes + while (length + 2 > (size_t)(TOTAL_CODEWORDS[v - 1] - ECC_PER_BLOCK[v - 1] * NUM_BLOCKS[v - 1])) { + if (++v > QR_ENCODE_MAX_VERSION) + return false; + } + + uint8_t size = QR_ENCODE_SIZE(v); + uint8_t total = TOTAL_CODEWORDS[v - 1]; + uint8_t eccLen = ECC_PER_BLOCK[v - 1]; + uint8_t blocks = NUM_BLOCKS[v - 1]; + uint8_t dataCap = total - eccLen * blocks; + uint8_t blockLen = dataCap / blocks; + + qr->version = v; + qr->size = size; + qr->modules = modules; + + // Data blocks then ECC blocks, each contiguous; interleaving happens during placement + uint8_t cw[MAX_CODEWORDS]; + memset(cw, 0, total); + uint16_t bit = 0; + appendBits(cw, &bit, 0x4, 4); // Byte mode + appendBits(cw, &bit, length, 8); + for (size_t i = 0; i < length; i++) + appendBits(cw, &bit, (uint8_t)text[i], 8); + // Terminator and byte-alignment bits are already zero + for (uint8_t i = (bit + 7) >> 3, pad = 0xEC; i < dataCap; i++, pad ^= 0xEC ^ 0x11) + cw[i] = pad; + + uint8_t coeff[MAX_ECC_PER_BLOCK]; + rsInit(eccLen, coeff); + for (uint8_t b = 0; b < blocks; b++) + rsRemainder(eccLen, coeff, cw + b * blockLen, blockLen, cw + dataCap + b * eccLen); + + memset(modules, 0, QR_ENCODE_BUFFER_SIZE(v)); + drawFunctionPatterns(modules, size); + + uint16_t i = 0; + uint8_t cur = 0; + for (int8_t right = size - 1; right >= 1; right -= 2) { + if (right == 6) + right = 5; + for (uint8_t vert = 0; vert < size; vert++) { + for (uint8_t j = 0; j < 2; j++) { + uint8_t x = right - j; + bool upwards = ((right & 2) == 0) ^ (x < 6); + uint8_t y = upwards ? size - 1 - vert : vert; + if (isFunction(size, x, y) || i >= total * 8) + continue; // Remainder bits stay light + if ((i & 7) == 0) { + uint8_t k = i >> 3; + if (k < dataCap) { + cur = cw[(k % blocks) * blockLen + k / blocks]; + } else { + k -= dataCap; + cur = cw[dataCap + (k % blocks) * eccLen + k / blocks]; + } + } + if (cur & (0x80 >> (i & 7))) + setBit(modules, size, x, y, true); + i++; + } + } + } + + uint8_t mask = 0; + uint32_t minPenalty = UINT32_MAX; + for (uint8_t m = 0; m < 8; m++) { + drawFormatBits(modules, size, m); + applyMask(modules, size, m); + uint32_t penalty = getPenaltyScore(modules, size); + if (penalty < minPenalty) { + mask = m; + minPenalty = penalty; + } + applyMask(modules, size, m); // XOR again to undo + } + + qr->mask = mask; + drawFormatBits(modules, size, mask); + applyMask(modules, size, mask); + return true; +} + +bool qrGetModule(const QrEncode *qr, uint8_t x, uint8_t y) +{ + if (x >= qr->size || y >= qr->size) + return false; + return getBit(qr->modules, qr->size, x, y); +} diff --git a/src/graphics/QrEncode.h b/src/graphics/QrEncode.h new file mode 100644 index 0000000000..9057074ca1 --- /dev/null +++ b/src/graphics/QrEncode.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +// Minimal QR encoder: ECC level M, versions 1-6 (21-41 modules, up to 106 bytes), byte mode only. + +#define QR_ENCODE_MAX_VERSION 6 +#define QR_ENCODE_SIZE(version) (4 * (version) + 17) +#define QR_ENCODE_BUFFER_SIZE(version) ((QR_ENCODE_SIZE(version) * QR_ENCODE_SIZE(version) + 7) / 8) + +struct QrEncode { + uint8_t version; + uint8_t size; + uint8_t mask; + uint8_t *modules; +}; + +// modules must hold QR_ENCODE_BUFFER_SIZE(QR_ENCODE_MAX_VERSION) bytes; picks the smallest version that fits. +bool qrEncodeText(QrEncode *qr, uint8_t *modules, const char *text); + +// true for a dark module; false outside the symbol +bool qrGetModule(const QrEncode *qr, uint8_t x, uint8_t y); diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 0350405e08..d3a9da2a56 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1542,6 +1542,14 @@ void Screen::setFrames(FrameFocus focus) } #endif +#if HAS_BLUETOOTH + if (config.bluetooth.enabled) { + normalFrames[numframes++] = graphics::DebugRenderer::drawFrameBluetoothPair; + indicatorIcons.push_back(icon_qr); + PUSH_FRAME_TITLE("Pair"); + } +#endif + // Beware of what changes you make in this code! // We pass numframes into GetMeshModulesWithUIFrames() which is highly important! // Inside of that callback, goes over to MeshModule.cpp and we run diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index da03cb0e99..63fa9c87d6 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -8,6 +8,7 @@ #include "UIRenderer.h" #include "airtime.h" #include "gps/RTC.h" +#include "graphics/QrEncode.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/TFTColorRegions.h" @@ -15,6 +16,7 @@ #include "graphics/TimeFormatters.h" #include "graphics/images.h" #include "main.h" +#include "meshUtils.h" #if HAS_WIFI && !defined(ARCH_PORTDUINO) #include "mesh/wifi/WiFiAPClient.h" @@ -648,6 +650,84 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1 display->drawString(textX, getTextPositions(display)[line++], "World!"); } +// The PIN pairing will ask for: the fixed one, or the random one picked when Bluetooth started +static bool getPairingPin(uint32_t *pin) +{ +#if defined(ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH + return nimbleBluetooth && nimbleBluetooth->getPasskey(pin); +#elif defined(ARCH_NRF52) + return nrf52Bluetooth && nrf52Bluetooth->getPasskey(pin); +#else + (void)pin; + return false; +#endif +} + +void drawFrameBluetoothPair(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + graphics::drawCommonHeader(display, x, y, "Pair"); + + uint32_t pin = 0; + const bool hasPin = getPairingPin(&pin); + char mac[18] = ""; + uint8_t addr[6]; + if (getBluetoothAddr(addr)) + formatMacAddr(addr, mac, sizeof(mac)); + + // Re-encode only when the link changes, not on every redraw + static char cachedLink[96]; + static uint8_t modules[QR_ENCODE_BUFFER_SIZE(QR_ENCODE_MAX_VERSION)]; + static QrEncode qr; + static bool valid = false; + char link[sizeof(cachedLink)]; + if (hasPin) + snprintf(link, sizeof(link), "meshtastic://meshtastic/connections?address=x%s&pin=%06u", mac, (unsigned)pin); + else + snprintf(link, sizeof(link), "meshtastic://meshtastic/connections?address=x%s", mac); + if (!mac[0]) { + valid = false; + cachedLink[0] = '\0'; // Re-encode once the address is back, even if unchanged + } else if (strcmp(link, cachedLink) != 0) { + strcpy(cachedLink, link); + valid = qrEncodeText(&qr, modules, link); + } + + const int top = getTextPositions(display)[1] + y; + if (!valid) { + display->drawString(x + BASEUI_BODY_LR_MARGIN, top, "No BLE address"); + return; + } + + // 2-module quiet zone; largest whole-pixel scale that fits below the header + const int span = qr.size + 4; + const int availH = display->getHeight() - (top - y); + const int availW = display->getWidth(); + int scale = (availH < availW ? availH : availW) / span; + if (scale < 1) { + display->drawString(x + BASEUI_BODY_LR_MARGIN, top, "Screen too small"); + return; + } + const int qrPx = span * scale; + const int qrX = x + (availW - qrPx) / 2; + const int qrY = top + (availH - qrPx) / 2; + + display->setColor(WHITE); + display->fillRect(qrX, qrY, qrPx, qrPx); + display->setColor(BLACK); + for (uint8_t my = 0; my < qr.size; my++) + for (uint8_t mx = 0; mx < qr.size; mx++) + if (qrGetModule(&qr, mx, my)) + display->fillRect(qrX + (mx + 2) * scale, qrY + (my + 2) * scale, scale, scale); + display->setColor(WHITE); +#if GRAPHICS_TFT_COLORING_ENABLED + // Keep dark-on-light whatever the theme, so phone scanners read it + graphics::registerTFTColorRegionDirect(qrX, qrY, qrPx, qrPx, graphics::TFTPalette::White, graphics::TFTPalette::Black); +#endif +} + } // namespace DebugRenderer } // namespace graphics #endif diff --git a/src/graphics/draw/DebugRenderer.h b/src/graphics/draw/DebugRenderer.h index 41b4c2bce6..0151ea0e23 100644 --- a/src/graphics/draw/DebugRenderer.h +++ b/src/graphics/draw/DebugRenderer.h @@ -27,6 +27,8 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); // Chirpy screen display void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +// QR code with the app's BLE connect link +void drawFrameBluetoothPair(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); } // namespace DebugRenderer } // namespace graphics diff --git a/src/graphics/images.h b/src/graphics/images.h index a4ae77e5f0..fc155a6092 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -121,6 +121,9 @@ const uint8_t icon_system[] PROGMEM = { 0x24 // Row 7: ..#..#.. }; +// QR code (BLE pairing) +const uint8_t icon_qr[] PROGMEM = {0xE7, 0xA5, 0xE7, 0x18, 0x18, 0x27, 0x25, 0xA7}; + // 🌐 Wi-Fi const uint8_t icon_wifi[] PROGMEM = {0b00000000, 0b00011000, 0b00111100, 0b01111110, 0b11011011, 0b00011000, 0b00011000, 0b00000000}; diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index e01c32c72d..22dca9e303 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -101,6 +101,8 @@ BLECharacteristic *logRadioCharacteristic; BLEServer *bleServer; static bool passkeyShowing; +static uint32_t configuredPasskey; +static bool passkeyConfigured; static std::atomic nimbleBluetoothConnHandle{BLE_HS_CONN_HANDLE_NONE}; // BLE_HS_CONN_HANDLE_NONE means "no connection" // Set by onDisconnect to defer (re)starting advertising to the main task. A stale-bond reconnect @@ -876,6 +878,14 @@ bool NimbleBluetooth::isActive() return bleServer != nullptr; } +bool NimbleBluetooth::getPasskey(uint32_t *pin) +{ + if (!passkeyConfigured) + return false; + *pin = configuredPasskey; + return true; +} + bool NimbleBluetooth::isConnected() { return nimbleBluetoothConnHandle.load() != BLE_HS_CONN_HANDLE_NONE; @@ -940,17 +950,19 @@ void NimbleBluetooth::setup() // Set the passkey if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN) { LOG_INFO("Use random passkey"); - security.setPassKey(false); // generate a random passkey + configuredPasskey = security.setPassKey(false); // generate a random passkey } else { LOG_INFO("Use fixed passkey"); - security.setPassKey(true, config.bluetooth.fixed_pin); + configuredPasskey = security.setPassKey(true, config.bluetooth.fixed_pin); } + passkeyConfigured = true; // Enable authorization requirements: // - bonding: true (for persistent storage of the keys) // - MITM: true (enables Man-In-The-Middle protection for password prompts) // - secure connection: true (enables secure connection for encryption) security.setAuthenticationMode(true, true, true); } else { + passkeyConfigured = false; // No IO capability for no PIN mode security.setCapability(ESP_IO_CAP_NONE); // No PIN mode: no MITM protection diff --git a/src/nimble/NimbleBluetooth.h b/src/nimble/NimbleBluetooth.h index dad0a8c985..9af5fd9624 100644 --- a/src/nimble/NimbleBluetooth.h +++ b/src/nimble/NimbleBluetooth.h @@ -11,6 +11,8 @@ class NimbleBluetooth : BluetoothApi bool isActive(); bool isConnected(); int getRssi(); + // The PIN pairing asks for; false in NO_PIN mode or before setup() + bool getPasskey(uint32_t *pin); void sendLog(const uint8_t *logMessage, size_t length); void startAdvertising(); bool isDeInit = false; diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 76eaae0789..af3e14a3d3 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -338,6 +338,16 @@ void setupMeshService(void) logRadio.begin(); } static uint32_t configuredPasskey; +static bool passkeyConfigured; + +bool NRF52Bluetooth::getPasskey(uint32_t *pin) +{ + if (!passkeyConfigured) + return false; + *pin = configuredPasskey; + return true; +} + void NRF52Bluetooth::shutdown() { // Shutdown bluetooth for minimum power draw @@ -404,6 +414,7 @@ void NRF52Bluetooth::setup() HardwareRNG::fill(reinterpret_cast(&hwrand), sizeof(hwrand)); configuredPasskey = hwrand % 900000u + 100000u; } + passkeyConfigured = true; auto pinString = std::to_string(configuredPasskey); LOG_INFO("Bluetooth pin set to '%i'", configuredPasskey); Bluefruit.Security.setPIN(pinString.c_str()); @@ -413,6 +424,7 @@ void NRF52Bluetooth::setup() Bluefruit.Security.setSecuredCallback(NRF52Bluetooth::onConnectionSecured); meshBleService.setPermission(SECMODE_ENC_WITH_MITM, SECMODE_ENC_WITH_MITM); } else { + passkeyConfigured = false; Bluefruit.Security.setIOCaps(false, false, false); meshBleService.setPermission(SECMODE_OPEN, SECMODE_OPEN); } diff --git a/src/platform/nrf52/NRF52Bluetooth.h b/src/platform/nrf52/NRF52Bluetooth.h index 630ab05bc8..c82905feb2 100644 --- a/src/platform/nrf52/NRF52Bluetooth.h +++ b/src/platform/nrf52/NRF52Bluetooth.h @@ -13,6 +13,8 @@ class NRF52Bluetooth : BluetoothApi void clearBonds(); bool isConnected(); int getRssi(); + // The PIN pairing asks for; false in NO_PIN mode or before setup() + bool getPasskey(uint32_t *pin); void sendLog(const uint8_t *logMessage, size_t length); private: diff --git a/test/test_qr_encode/test_main.cpp b/test/test_qr_encode/test_main.cpp new file mode 100644 index 0000000000..da9e39425b --- /dev/null +++ b/test/test_qr_encode/test_main.cpp @@ -0,0 +1,106 @@ +// Unit tests for qrEncodeText() / qrGetModule() in src/graphics/QrEncode.cpp - the encoder behind the +// BLE pairing QR frame (DebugRenderer::drawFrameBluetoothPair). +// +// The frame encodes the app's connect link, meshtastic://meshtastic/connections?address=x&pin=, +// which a phone camera must read back byte for byte or the app opens on the wrong device or PIN. The +// encoder is a cut-down ricmoo/QRCode fixed at ECC level M, versions 1-6 and byte mode, so a symbol is +// only correct if every module matches what a full encoder produces for the same version and mask. +// +// Regressions guarded: +// - any change to placement, interleaving, Reed-Solomon, masking or format bits that moves a module. +// The reference matrix below is Project Nayuki's encoder output for the 73-character link (version 5, +// mask 2, which both encoders choose), and it decodes with zxing; +// - picking a larger version than needed (a coarser code on 128x64 panels) or a smaller one that the +// data overflows, at each version's byte-mode capacity boundary; +// - writing past the module buffer instead of rejecting data longer than version 6 holds. +#include "TestUtil.h" +#include "graphics/QrEncode.h" +#include +#include +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +static const char *const kLink = "meshtastic://meshtastic/connections?address=xAC:27:6E:CD:5A:99&pin=317338"; + +static const char *const kLinkModules[] = { + "1111111000101001111011111001101111111", "1000001000101101010100000101101000001", "1011101010100110101111100011101011101", + "1011101011000100110100111111101011101", "1011101011001001000111010000101011101", "1000001011100010001010000001101000001", + "1111111010101010101010101010101111111", "0000000010001000100111110011100000000", "1011111001000011001110010101101111100", + "0011010001110101100000111110010001110", "1000111101001010011010101001000011011", "1111000110101110100001110000011010011", + "0000101001100100111000111110011011011", "1001010101100010110111011110100101010", "1010111010100111110011000101111000111", + "0101000011011001111001111001100100011", "1101101011001111100100001100111111110", "1010100110001101011010110010001001000", + "1110001001011101110100100011001110011", "0111010000110111000111001001110100011", "0010011011010111010100111111001111010", + "0100110101101110101111010100110101110", "0011101000101001001000100001111111001", "0111010000111100000011101010111000010", + "0101001101111101001000011100001110101", "1110010110110111100000110011110001000", "1011001100000100001000101001100010111", + "1000110011111100100001001010000001011", "1010001010011010110110101111111110100", "0000000010001100111111010111100010010", + "1111111000001111010010001000101011011", "1000001010101101111101100011100010010", "1011101011001001100010000101111111101", + "1011101011000001001101110000101111101", "1011101011001011100011101100100101111", "1000001000000111001101110011000101001", + "1111111011011101010000100110000000111"}; + +static uint8_t modules[QR_ENCODE_BUFFER_SIZE(QR_ENCODE_MAX_VERSION)]; + +static void test_connect_link_matches_reference_symbol() +{ + QrEncode qr; + TEST_ASSERT_TRUE(qrEncodeText(&qr, modules, kLink)); + TEST_ASSERT_EQUAL_UINT8(5, qr.version); + TEST_ASSERT_EQUAL_UINT8(37, qr.size); + TEST_ASSERT_EQUAL_UINT8(2, qr.mask); + for (uint8_t y = 0; y < qr.size; y++) { + char row[38]; + for (uint8_t x = 0; x < qr.size; x++) + row[x] = qrGetModule(&qr, x, y) ? '1' : '0'; + row[qr.size] = '\0'; + TEST_ASSERT_EQUAL_STRING_MESSAGE(kLinkModules[y], row, "module row differs from reference"); + } +} + +// Byte-mode capacity at ECC M: 14, 26, 42, 62, 84, 106 characters for versions 1-6. +static void test_smallest_version_is_chosen_at_each_capacity_boundary() +{ + static const uint16_t capacity[6] = {14, 26, 42, 62, 84, 106}; + for (uint8_t v = 1; v <= 6; v++) { + QrEncode qr; + std::string fits(capacity[v - 1], 'a'); + TEST_ASSERT_TRUE(qrEncodeText(&qr, modules, fits.c_str())); + TEST_ASSERT_EQUAL_UINT8(v, qr.version); + TEST_ASSERT_EQUAL_UINT8(4 * v + 17, qr.size); + if (v < 6) { + std::string over(capacity[v - 1] + 1, 'a'); + TEST_ASSERT_TRUE(qrEncodeText(&qr, modules, over.c_str())); + TEST_ASSERT_EQUAL_UINT8(v + 1, qr.version); + } + } +} + +static void test_data_beyond_version_6_is_rejected() +{ + QrEncode qr; + std::string tooLong(107, 'a'); + TEST_ASSERT_FALSE(qrEncodeText(&qr, modules, tooLong.c_str())); +} + +static void test_getModule_outside_symbol_is_light() +{ + QrEncode qr; + TEST_ASSERT_TRUE(qrEncodeText(&qr, modules, "x")); + TEST_ASSERT_TRUE(qrGetModule(&qr, 0, 0)); // finder corner + TEST_ASSERT_FALSE(qrGetModule(&qr, qr.size, 0)); + TEST_ASSERT_FALSE(qrGetModule(&qr, 0, qr.size)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_connect_link_matches_reference_symbol); + RUN_TEST(test_smallest_version_is_chosen_at_each_capacity_boundary); + RUN_TEST(test_data_beyond_version_6_is_rejected); + RUN_TEST(test_getModule_outside_symbol_is_light); + exit(UNITY_END()); +} + +void loop() {}