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<BLE MAC>[&pin=<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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011C7D9bSthe6aUpG3jEHLyt
This commit is contained in:
Jonathan BennettandClaude Opus 5.5 committed 2026-11-04 19:49:35 -06:00
1 parent 9ba93703be
commit 2d74892ddf
11 files changed
+594 -2

No files matched your search

+343
View File
@@ -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 <string.h>
// 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);
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stdint.h>
// 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);
+8
View File
@@ -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
+80
View File
@@ -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
+2
View File
@@ -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
+3
View File
@@ -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};
+14 -2
View File
@@ -101,6 +101,8 @@ BLECharacteristic *logRadioCharacteristic;
BLEServer *bleServer;
static bool passkeyShowing;
static uint32_t configuredPasskey;
static bool passkeyConfigured;
static std::atomic<uint16_t> 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
+2
View File
@@ -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;
+12
View File
@@ -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<uint8_t *>(&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);
}
+2
View File
@@ -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:
+106
View File
@@ -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<MAC>&pin=<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 <Arduino.h>
#include <cstring>
#include <string>
#include <unity.h>
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() {}