Community project
UNIHIKER K10 Controller

The UNIHIKER K10 Controller is a Bluetooth proximity scanner built on the ESP32 platform that detects and measures nearby BLE devices. This guide provides everything needed to assemble and program a working proximity detector, including a wiring diagram, complete parts list, and step-by-step assembly instructions.
The project uses the UNIHIKER K10 board's built-in display and controls to scan for Bluetooth devices, display signal strength (RSSI), and track proximity changes over time. Readers will learn how to set up the hardware, upload the firmware, and use the interactive interface to monitor nearby BLE beacons and devices.
Wiring diagram
Interactive · read-onlyPan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Assembly
3 stepsPrepare the UNIHIKER K10
Place the UNIHIKER K10 on a dry, non-conductive surface with the screen and built-in speaker unobstructed. This project uses only the board’s built-in display, A/B buttons, Bluetooth radio, and speaker—do not connect external parts.
- Tip: Keep the device within several metres of the Bluetooth device you want to locate during the first test.
- Tip: A phone or tracker must have Bluetooth enabled and be advertising to appear in the list.
- ⚠ Do not short the exposed edge pins or place the powered board on metal.
- ⚠ Keep sound volume comfortable; the close-range beeps can be frequent.
Power the board
Connect the K10 to a reliable USB-C power source or your computer with a USB-C cable. The project is designed for normal USB power.
- Tip: The screen should light when the board starts.
- ⚠ Use an undamaged USB cable and a suitable 5 V USB supply.
Use the proximity scanner
After deploying, wait for the initial five-second Bluetooth Low Energy (BLE) scan. Press Button A to move the on-screen selection, then press Button B to choose a device. The K10 rescans the selected device about every five seconds; stronger received signal produces shorter intervals and higher-pitched beeps. Press Button B again to return to the list.
- Tip: RSSI is a radio signal estimate, not a precise distance measurement. Walls, people, device orientation, and phone power-saving settings affect it.
- Tip: If a device disappears, move closer or make sure it is still advertising Bluetooth; return to the list and scan again if needed.
- ⚠ This does not pair with, connect to, or track classic Bluetooth-only devices; it lists nearby BLE advertisements only.
Firmware
ESP32#include <Arduino.h>
#include "unihiker_k10.h"
#include <NimBLEDevice.h>
struct NearbyDevice {
String name;
String address;
int rssi;
};
enum AppState {
STATE_INITIAL_SCAN,
STATE_DEVICE_LIST,
STATE_MEASURING
};
// Forward declarations
void setLabelText(lv_obj_t *label, const String &text);
String deviceLabel(const NimBLEAdvertisedDevice &device);
String rssiDescription(int rssi);
void clearRows();
void showScanningScreen(const String &message);
void showDeviceList();
void showSelectedDevice();
void collectInitialScanResults();
void collectMeasurementResult();
void startInitialScan();
void startMeasurementScan();
void beepForSignal();
void setupUi();
static const uint8_t MAX_DEVICES = 12;
static const uint8_t VISIBLE_ROWS = 5;
static const uint32_t FULL_SCAN_SECONDS = 10;
static const uint32_t MEASUREMENT_SCAN_SECONDS = 5;
static const uint32_t MEASUREMENT_SCAN_PERIOD_MS = 5000;
UNIHIKER_K10 k10;
Music music;
NimBLEScan *bleScan = nullptr;
NearbyDevice devices[MAX_DEVICES];
uint8_t deviceCount = 0;
// In the menu, index deviceCount is always the dedicated Rescan item.
uint8_t selectedIndex = 0;
int selectedRssi = -127;
AppState appState = STATE_INITIAL_SCAN;
bool previousA = false;
bool previousB = false;
unsigned long comboStartedMs = 0;
bool comboWasHeld = false;
bool measurementScanActive = false;
unsigned long nextMeasurementScanMs = 0;
unsigned long lastBeepMs = 0;
lv_obj_t *titleLabel;
lv_obj_t *infoLabel;
lv_obj_t *rows[VISIBLE_ROWS];
lv_obj_t *hintLabel;
void setLabelText(lv_obj_t *label, const String &text) {
lv_label_set_text(label, text.c_str());
}
String deviceLabel(const NimBLEAdvertisedDevice &device) {
String label;
if (device.haveName() && device.getName().length() > 0) {
label = String(device.getName().c_str());
} else {
label = String(device.getAddress().toString().c_str());
}
// Leave room for the RSSI column on the 320-pixel landscape screen.
if (label.length() > 22) label = label.substring(0, 22) + "...";
return label;
}
String rssiDescription(int rssi) {
if (rssi >= -55) return "Very strong / close";
if (rssi >= -67) return "Strong";
if (rssi >= -80) return "Fair";
if (rssi >= -92) return "Weak / farther away";
return "Very weak / may be leaving range";
}
void clearRows() {
for (uint8_t i = 0; i < VISIBLE_ROWS; i++) setLabelText(rows[i], "");
}
void showScanningScreen(const String &message) {
setLabelText(titleLabel, "BLE device scan");
setLabelText(infoLabel, message);
clearRows();
setLabelText(rows[1], "Searching for Bluetooth Low Energy advertisements");
setLabelText(rows[2], "The device list appears only after this scan ends.");
setLabelText(hintLabel, "Please wait - navigation is disabled while scanning");
}
void showDeviceList() {
appState = STATE_DEVICE_LIST;
const uint8_t itemCount = deviceCount + 1; // devices plus the Rescan item
if (selectedIndex >= itemCount) selectedIndex = 0;
setLabelText(titleLabel, "Nearby BLE devices");
setLabelText(infoLabel, String("A: up B: down A+B: select ") +
String(selectedIndex + 1) + "/" + String(itemCount));
setLabelText(hintLabel, "Choose a device to measure, or select Rescan all devices");
const uint8_t pageStart = (selectedIndex / VISIBLE_ROWS) * VISIBLE_ROWS;
for (uint8_t row = 0; row < VISIBLE_ROWS; row++) {
const uint8_t index = pageStart + row;
if (index >= itemCount) {
setLabelText(rows[row], "");
continue;
}
const bool highlighted = (index == selectedIndex);
const String prefix = highlighted ? "> " : " ";
if (index < deviceCount) {
setLabelText(rows[row], prefix + devices[index].name + " " +
String(devices[index].rssi) + " dBm");
} else {
setLabelText(rows[row], prefix + String("[ Rescan all devices ]"));
}
lv_obj_set_style_text_color(rows[row],
highlighted ? lv_color_hex(0x00D8FF) : lv_color_white(), 0);
}
}
void showSelectedDevice() {
setLabelText(titleLabel, "Signal proximity beeper");
setLabelText(rows[0], devices[selectedIndex].name);
setLabelText(rows[1], String("Address: ") + devices[selectedIndex].address);
if (bleScan->isScanning()) {
setLabelText(infoLabel, "Measuring selected device...");
setLabelText(rows[2], "Scanning this area now");
} else if (selectedRssi <= -120) {
setLabelText(infoLabel, "Not seen in the last measurement scan");
setLabelText(rows[2], "Move closer, or it stopped advertising");
} else {
setLabelText(infoLabel, String("RSSI: ") + String(selectedRssi) + " dBm");
setLabelText(rows[2], rssiDescription(selectedRssi));
}
setLabelText(rows[3], "Stronger signal = faster, higher beeps");
setLabelText(rows[4], "");
setLabelText(hintLabel, "Hold A+B for 0.7 seconds: return to the device list");
}
void collectInitialScanResults() {
const NimBLEScanResults &results = bleScan->getResults();
deviceCount = 0;
for (int i = 0; i < results.getCount() && deviceCount < MAX_DEVICES; i++) {
const NimBLEAdvertisedDevice *device = results.getDevice(i);
if (device == nullptr) continue;
devices[deviceCount].name = deviceLabel(*device);
devices[deviceCount].address = String(device->getAddress().toString().c_str());
devices[deviceCount].rssi = device->getRSSI();
deviceCount++;
}
bleScan->clearResults();
selectedIndex = 0;
// This is called only after NimBLE reports the complete full scan has ended.
showDeviceList();
}
void collectMeasurementResult() {
const NimBLEScanResults &results = bleScan->getResults();
bool matched = false;
for (int i = 0; i < results.getCount(); i++) {
const NimBLEAdvertisedDevice *device = results.getDevice(i);
if (device == nullptr) continue;
if (String(device->getAddress().toString().c_str()) == devices[selectedIndex].address) {
selectedRssi = device->getRSSI();
devices[selectedIndex].rssi = selectedRssi;
matched = true;
break;
}
}
if (!matched) selectedRssi = -127;
bleScan->clearResults();
measurementScanActive = false;
nextMeasurementScanMs = millis() + MEASUREMENT_SCAN_PERIOD_MS;
showSelectedDevice();
}
void startInitialScan() {
appState = STATE_INITIAL_SCAN;
measurementScanActive = false;
bleScan->clearResults();
showScanningScreen("Full scan in progress (10 seconds)");
// Do not show the list until isScanning() becomes false in loop().
bleScan->start(FULL_SCAN_SECONDS, false, true);
}
void startMeasurementScan() {
if (appState != STATE_MEASURING || measurementScanActive || bleScan->isScanning()) return;
bleScan->clearResults();
measurementScanActive = true;
bleScan->start(MEASUREMENT_SCAN_SECONDS, false, true);
showSelectedDevice();
}
void beepForSignal() {
if (selectedRssi <= -120 || bleScan->isScanning()) return;
const int clampedRssi = constrain(selectedRssi, -100, -40);
const unsigned long intervalMs = map(clampedRssi, -100, -40, 1400, 220);
if (millis() - lastBeepMs < intervalMs) return;
const int frequency = map(clampedRssi, -100, -40, 440, 1568);
music.playTone(frequency, 70);
lastBeepMs = millis();
}
void setupUi() {
k10.initScreen(2); // 320 x 240 landscape
k10.setScreenBackground(0x102030);
// Each label is fixed to 308 pixels and clipped. This prevents long names,
// addresses, and hints from crossing the edge of the landscape display.
titleLabel = lv_label_create(lv_scr_act());
lv_obj_set_size(titleLabel, 308, 18);
lv_obj_align(titleLabel, LV_ALIGN_TOP_MID, 0, 5);
lv_obj_set_style_text_color(titleLabel, lv_color_hex(0x00D8FF), 0);
lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(titleLabel, LV_LABEL_LONG_CLIP);
infoLabel = lv_label_create(lv_scr_act());
lv_obj_set_size(infoLabel, 308, 18);
lv_obj_align(infoLabel, LV_ALIGN_TOP_MID, 0, 28);
lv_obj_set_style_text_color(infoLabel, lv_color_white(), 0);
lv_label_set_long_mode(infoLabel, LV_LABEL_LONG_CLIP);
for (uint8_t i = 0; i < VISIBLE_ROWS; i++) {
rows[i] = lv_label_create(lv_scr_act());
lv_obj_set_size(rows[i], 308, 19);
lv_obj_align(rows[i], LV_ALIGN_TOP_MID, 0, 53 + i * 24);
lv_label_set_long_mode(rows[i], LV_LABEL_LONG_CLIP);
lv_obj_set_style_text_font(rows[i], &lv_font_montserrat_14, 0);
}
hintLabel = lv_label_create(lv_scr_act());
lv_obj_set_size(hintLabel, 308, 38);
lv_obj_align(hintLabel, LV_ALIGN_BOTTOM_MID, 0, -4);
lv_obj_set_style_text_color(hintLabel, lv_color_hex(0xB0C8D8), 0);
lv_label_set_long_mode(hintLabel, LV_LABEL_LONG_WRAP);
}
void setup() {
k10.begin();
setupUi();
NimBLEDevice::init("K10 BLE proximity scanner");
bleScan = NimBLEDevice::getScan();
bleScan->setActiveScan(true);
bleScan->setInterval(1349);
bleScan->setWindow(449);
startInitialScan();
}
void loop() {
lv_timer_handler();
const bool buttonA = k10.buttonA->isPressed();
const bool buttonB = k10.buttonB->isPressed();
const bool pressedA = buttonA && !previousA;
const bool pressedB = buttonB && !previousB;
const bool bothPressed = buttonA && buttonB;
if (!bothPressed) {
comboStartedMs = 0;
comboWasHeld = false;
}
if (appState == STATE_INITIAL_SCAN) {
// Navigation remains unavailable until the requested full scan is done.
if (!bleScan->isScanning()) collectInitialScanResults();
} else if (appState == STATE_DEVICE_LIST) {
const uint8_t itemCount = deviceCount + 1;
if (bothPressed && !comboWasHeld) {
comboWasHeld = true;
if (selectedIndex == deviceCount) {
startInitialScan();
} else {
selectedRssi = devices[selectedIndex].rssi;
appState = STATE_MEASURING;
measurementScanActive = false;
nextMeasurementScanMs = 0;
showSelectedDevice();
}
} else if (!bothPressed && pressedA) {
selectedIndex = (selectedIndex == 0) ? itemCount - 1 : selectedIndex - 1;
showDeviceList();
} else if (!bothPressed && pressedB) {
selectedIndex = (selectedIndex + 1) % itemCount;
showDeviceList();
}
} else { // STATE_MEASURING
if (bothPressed && !comboWasHeld) {
if (comboStartedMs == 0) comboStartedMs = millis();
if (millis() - comboStartedMs >= 700) {
comboWasHeld = true;
if (bleScan->isScanning()) bleScan->stop();
measurementScanActive = false;
bleScan->clearResults();
showDeviceList();
}
}
if (appState == STATE_MEASURING) {
if (measurementScanActive && !bleScan->isScanning()) {
collectMeasurementResult();
} else if (!measurementScanActive &&
(nextMeasurementScanMs == 0 || millis() >= nextMeasurementScanMs)) {
startMeasurementScan();
} else if (!measurementScanActive) {
beepForSignal();
}
}
}
previousA = buttonA;
previousB = buttonB;
delay(5);
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.