Add CYD BLE foundation with NimBLE Nordic UART.

Reusable ble_core (+ optional LVGL ble_ui), flashable firmware/cyd, aligned with wifitest as a base feature.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 15:43:11 +08:00
commit 8baea73c0c
23 changed files with 2338 additions and 0 deletions

257
src/ble_core.cpp Normal file
View File

@@ -0,0 +1,257 @@
#include "ble_core.h"
#include <NimBLEDevice.h>
#include <WiFi.h>
#include <cstring>
static NimBLEServer *g_server = nullptr;
static NimBLECharacteristic *g_tx = nullptr;
static NimBLECharacteristic *g_rx = nullptr;
static BleCoreState g_state = BLE_CORE_IDLE;
static BleCoreStateCb g_state_cb = nullptr;
static void *g_state_user = nullptr;
static BleCoreRxCb g_rx_cb = nullptr;
static void *g_rx_user = nullptr;
static char g_name[32] = BLE_CORE_DEVICE_NAME;
static bool g_echo = true;
static bool g_want_adv = true;
static bool g_connected = false;
static char g_peer[24] = {};
static BleRxPacket g_rx_q[BLE_CORE_RX_QUEUE];
static uint8_t g_rx_head = 0;
static uint8_t g_rx_tail = 0;
static uint8_t g_rx_count = 0;
static void set_state(BleCoreState s) {
if (g_state == s) {
return;
}
g_state = s;
if (g_state_cb) {
g_state_cb(s, g_state_user);
}
}
static void enqueue_rx(const uint8_t *data, size_t len) {
if (!data || len == 0) {
return;
}
if (len > BLE_CORE_RX_MAX) {
len = BLE_CORE_RX_MAX;
}
BleRxPacket *slot = &g_rx_q[g_rx_tail];
memcpy(slot->data, data, len);
slot->data[len] = '\0';
slot->len = static_cast<uint16_t>(len);
g_rx_tail = (g_rx_tail + 1) % BLE_CORE_RX_QUEUE;
if (g_rx_count < BLE_CORE_RX_QUEUE) {
g_rx_count++;
} else {
g_rx_head = (g_rx_head + 1) % BLE_CORE_RX_QUEUE;
}
if (g_rx_cb) {
g_rx_cb(data, len, g_rx_user);
}
}
class ServerCbs : public NimBLEServerCallbacks {
void onConnect(NimBLEServer *server, NimBLEConnInfo &connInfo) override {
(void)server;
g_connected = true;
snprintf(g_peer, sizeof(g_peer), "%s",
connInfo.getAddress().toString().c_str());
Serial.printf("[ble] connected peer=%s\n", g_peer);
set_state(BLE_CORE_CONNECTED);
}
void onDisconnect(NimBLEServer *server, NimBLEConnInfo &connInfo,
int reason) override {
(void)server;
(void)connInfo;
g_connected = false;
g_peer[0] = '\0';
Serial.printf("[ble] disconnected reason=%d\n", reason);
if (g_want_adv) {
set_state(BLE_CORE_ADVERTISING);
NimBLEDevice::startAdvertising();
} else {
set_state(BLE_CORE_IDLE);
}
}
};
class RxCbs : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *chr, NimBLEConnInfo &connInfo) override {
(void)connInfo;
NimBLEAttValue val = chr->getValue();
const size_t len = val.size();
if (len == 0) {
return;
}
const uint8_t *data = val.data();
Serial.printf("[ble] rx %u bytes: ", (unsigned)len);
for (size_t i = 0; i < len; ++i) {
const char c = static_cast<char>(data[i]);
Serial.print((c >= 32 && c < 127) ? c : '.');
}
Serial.println();
enqueue_rx(data, len);
if (g_echo && g_tx && g_connected) {
g_tx->setValue(data, len);
g_tx->notify();
}
}
};
static ServerCbs g_server_cbs;
static RxCbs g_rx_cbs;
void ble_core_set_state_cb(BleCoreStateCb cb, void *user) {
g_state_cb = cb;
g_state_user = user;
}
void ble_core_set_rx_cb(BleCoreRxCb cb, void *user) {
g_rx_cb = cb;
g_rx_user = user;
}
bool ble_core_begin(const char *device_name) {
if (g_server) {
return true;
}
// 关掉 WiFi 射频,给 BLE 腾 RAM;后续与 WiFi 共存时再改
WiFi.mode(WIFI_OFF);
if (device_name && device_name[0]) {
strncpy(g_name, device_name, sizeof(g_name) - 1);
g_name[sizeof(g_name) - 1] = '\0';
}
NimBLEDevice::init(g_name);
NimBLEDevice::setPower(ESP_PWR_LVL_P9);
g_server = NimBLEDevice::createServer();
g_server->setCallbacks(&g_server_cbs);
NimBLEService *nus = g_server->createService(BLE_NUS_SERVICE_UUID);
g_tx = nus->createCharacteristic(BLE_NUS_TX_UUID, NIMBLE_PROPERTY::NOTIFY);
g_rx = nus->createCharacteristic(
BLE_NUS_RX_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);
g_rx->setCallbacks(&g_rx_cbs);
NimBLEAdvertising *adv = NimBLEDevice::getAdvertising();
adv->setName(g_name);
adv->addServiceUUID(BLE_NUS_SERVICE_UUID);
adv->enableScanResponse(true);
g_want_adv = true;
NimBLEDevice::startAdvertising();
set_state(BLE_CORE_ADVERTISING);
Serial.printf("[ble] begin name=\"%s\" addr=%s NUS ready\n", g_name,
NimBLEDevice::getAddress().toString().c_str());
return true;
}
void ble_core_end() {
if (!g_server) {
return;
}
g_want_adv = false;
NimBLEDevice::stopAdvertising();
NimBLEDevice::deinit(true);
g_server = nullptr;
g_tx = nullptr;
g_rx = nullptr;
g_connected = false;
g_peer[0] = '\0';
set_state(BLE_CORE_IDLE);
}
BleCoreState ble_core_state() { return g_state; }
bool ble_core_is_connected() { return g_connected; }
bool ble_core_is_advertising() {
return g_state == BLE_CORE_ADVERTISING;
}
const char *ble_core_name() { return g_name; }
String ble_core_address() {
if (!g_server) {
return String();
}
return String(NimBLEDevice::getAddress().toString().c_str());
}
String ble_core_peer_address() { return String(g_peer); }
void ble_core_advertise_start() {
g_want_adv = true;
if (g_connected) {
return;
}
if (!g_server) {
return;
}
NimBLEDevice::startAdvertising();
set_state(BLE_CORE_ADVERTISING);
Serial.println("[ble] advertising start");
}
void ble_core_advertise_stop() {
g_want_adv = false;
if (!g_server) {
return;
}
NimBLEDevice::stopAdvertising();
if (!g_connected) {
set_state(BLE_CORE_IDLE);
}
Serial.println("[ble] advertising stop");
}
bool ble_core_send(const uint8_t *data, size_t len) {
if (!g_tx || !g_connected || !data || len == 0) {
return false;
}
g_tx->setValue(data, len);
g_tx->notify();
return true;
}
bool ble_core_send_str(const char *text) {
if (!text) {
return false;
}
return ble_core_send(reinterpret_cast<const uint8_t *>(text), strlen(text));
}
void ble_core_set_echo(bool on) { g_echo = on; }
bool ble_core_echo() { return g_echo; }
bool ble_core_poll_rx(BleRxPacket *out) {
if (!out || g_rx_count == 0) {
return false;
}
*out = g_rx_q[g_rx_head];
g_rx_head = (g_rx_head + 1) % BLE_CORE_RX_QUEUE;
g_rx_count--;
return true;
}
void ble_core_loop() {
// 断线重广播已在 onDisconnect 处理;此处预留共存/省电扩展点
}

277
src/ble_ui.cpp Normal file
View File

@@ -0,0 +1,277 @@
#include "ble_ui.h"
#include "ble_core.h"
#include "pins.h"
#include <Arduino.h>
#include <cstdio>
#include <cstring>
#include <lvgl.h>
static constexpr uint32_t COL_BG = 0x0A1016;
static constexpr uint32_t COL_INK = 0xEAF0F6;
static constexpr uint32_t COL_MUTED = 0x7A8B9C;
static constexpr uint32_t COL_OK = 0x3DDC97;
static constexpr uint32_t COL_WARN = 0xF0C040;
static constexpr uint32_t COL_BAD = 0xE85D4C;
static constexpr uint32_t COL_ACCENT = 0x4FC3F7;
static constexpr uint32_t COL_BTN = 0x243140;
static constexpr uint32_t COL_BTN_ACC = 0x1A4A6E;
static constexpr uint32_t COL_BTN_WARN = 0x6B3A1E;
static lv_obj_t *scr = nullptr;
static lv_obj_t *lbl_title = nullptr;
static lv_obj_t *lbl_state = nullptr;
static lv_obj_t *lbl_name = nullptr;
static lv_obj_t *lbl_addr = nullptr;
static lv_obj_t *lbl_peer = nullptr;
static lv_obj_t *lbl_echo = nullptr;
static lv_obj_t *lbl_log = nullptr;
static lv_obj_t *btn_adv = nullptr;
static lv_obj_t *lbl_adv = nullptr;
static BleCoreState g_last_state = BLE_CORE_IDLE;
static bool g_last_echo = true;
static char g_log[160] = "Waiting for phone...";
static uint32_t g_last_refresh_ms = 0;
static void style_btn(lv_obj_t *btn, uint32_t bg) {
lv_obj_set_style_radius(btn, 8, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_shadow_width(btn, 0, 0);
lv_obj_set_style_border_width(btn, 0, 0);
lv_obj_set_style_pad_all(btn, 0, 0);
}
static lv_obj_t *make_btn(lv_obj_t *parent, const char *text, uint32_t bg,
lv_event_cb_t cb, int w, int h) {
lv_obj_t *btn = lv_button_create(parent);
lv_obj_set_size(btn, w, h);
style_btn(btn, bg);
lv_obj_t *lab = lv_label_create(btn);
lv_label_set_text(lab, text);
lv_obj_set_style_text_color(lab, lv_color_hex(COL_INK), 0);
lv_obj_set_style_text_font(lab, &lv_font_montserrat_14, 0);
lv_obj_center(lab);
if (cb) {
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, nullptr);
}
return btn;
}
static void append_log(const char *line) {
if (!line) {
return;
}
char next[160];
snprintf(next, sizeof(next), "%s", line);
// 保留最近两行:旧半 + 新
char combined[160];
snprintf(combined, sizeof(combined), "%s\n%s", g_log, next);
// 若太长,只留新内容
if (strlen(combined) >= sizeof(g_log)) {
snprintf(g_log, sizeof(g_log), "%s", next);
} else {
snprintf(g_log, sizeof(g_log), "%s", combined);
}
if (lbl_log) {
lv_label_set_text(lbl_log, g_log);
}
}
static void refresh_static() {
if (lbl_name) {
char buf[48];
snprintf(buf, sizeof(buf), "Name %s", ble_core_name());
lv_label_set_text(lbl_name, buf);
}
if (lbl_addr) {
char buf[56];
String a = ble_core_address();
snprintf(buf, sizeof(buf), "Addr %s", a.length() ? a.c_str() : "-");
lv_label_set_text(lbl_addr, buf);
}
}
static void refresh_state() {
const BleCoreState st = ble_core_state();
g_last_state = st;
uint32_t color = COL_MUTED;
const char *text = "Idle";
if (st == BLE_CORE_ADVERTISING) {
color = COL_WARN;
text = "Advertising";
} else if (st == BLE_CORE_CONNECTED) {
color = COL_OK;
text = "Connected";
}
if (lbl_state) {
char buf[40];
snprintf(buf, sizeof(buf), "State %s", text);
lv_label_set_text(lbl_state, buf);
lv_obj_set_style_text_color(lbl_state, lv_color_hex(color), 0);
}
if (lbl_peer) {
char buf[56];
if (ble_core_is_connected()) {
String p = ble_core_peer_address();
snprintf(buf, sizeof(buf), "Peer %s", p.c_str());
} else {
snprintf(buf, sizeof(buf), "Peer -");
}
lv_label_set_text(lbl_peer, buf);
}
if (lbl_adv) {
lv_label_set_text(lbl_adv,
ble_core_is_advertising() || ble_core_is_connected()
? "Stop Adv"
: "Start Adv");
}
if (lbl_echo) {
char buf[32];
snprintf(buf, sizeof(buf), "Echo %s", ble_core_echo() ? "ON" : "OFF");
lv_label_set_text(lbl_echo, buf);
lv_obj_set_style_text_color(
lbl_echo, lv_color_hex(ble_core_echo() ? COL_OK : COL_MUTED), 0);
}
g_last_echo = ble_core_echo();
}
static void on_adv(lv_event_t *e) {
(void)e;
if (ble_core_is_connected()) {
// 已连接时 Stop Adv 无意义;改为提示
append_log("Connected — disconnect on phone first");
return;
}
if (ble_core_is_advertising()) {
ble_core_advertise_stop();
append_log("Advertising stopped");
} else {
ble_core_advertise_start();
append_log("Advertising started");
}
refresh_state();
}
static void on_echo(lv_event_t *e) {
(void)e;
ble_core_set_echo(!ble_core_echo());
append_log(ble_core_echo() ? "Echo ON" : "Echo OFF");
refresh_state();
}
static void on_ping(lv_event_t *e) {
(void)e;
if (!ble_core_is_connected()) {
append_log("Not connected");
return;
}
if (ble_core_send_str("Hello from CYD\n")) {
append_log("TX: Hello from CYD");
} else {
append_log("TX failed");
}
}
static void on_clear(lv_event_t *e) {
(void)e;
snprintf(g_log, sizeof(g_log), "(cleared)");
if (lbl_log) {
lv_label_set_text(lbl_log, g_log);
}
}
void ble_ui_create() {
scr = lv_obj_create(nullptr);
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
lv_screen_load(scr);
lbl_title = lv_label_create(scr);
lv_label_set_text(lbl_title, "CYD BLE");
lv_obj_set_style_text_color(lbl_title, lv_color_hex(COL_ACCENT), 0);
lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_20, 0);
lv_obj_set_pos(lbl_title, 10, 6);
lbl_state = lv_label_create(scr);
lv_obj_set_style_text_font(lbl_state, &lv_font_montserrat_14, 0);
lv_obj_set_pos(lbl_state, 10, 34);
lbl_name = lv_label_create(scr);
lv_obj_set_style_text_color(lbl_name, lv_color_hex(COL_INK), 0);
lv_obj_set_style_text_font(lbl_name, &lv_font_montserrat_14, 0);
lv_obj_set_pos(lbl_name, 10, 54);
lbl_addr = lv_label_create(scr);
lv_obj_set_style_text_color(lbl_addr, lv_color_hex(COL_MUTED), 0);
lv_obj_set_style_text_font(lbl_addr, &lv_font_montserrat_14, 0);
lv_obj_set_pos(lbl_addr, 10, 74);
lbl_peer = lv_label_create(scr);
lv_obj_set_style_text_color(lbl_peer, lv_color_hex(COL_MUTED), 0);
lv_obj_set_style_text_font(lbl_peer, &lv_font_montserrat_14, 0);
lv_obj_set_pos(lbl_peer, 10, 94);
lbl_echo = lv_label_create(scr);
lv_obj_set_style_text_font(lbl_echo, &lv_font_montserrat_14, 0);
lv_obj_set_pos(lbl_echo, 10, 114);
lv_obj_t *hint = lv_label_create(scr);
lv_label_set_text(hint, "Phone: nRF Connect / Serial BT Terminal");
lv_obj_set_style_text_color(hint, lv_color_hex(COL_MUTED), 0);
lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
lv_obj_set_pos(hint, 10, 134);
lbl_log = lv_label_create(scr);
lv_label_set_text(lbl_log, g_log);
lv_label_set_long_mode(lbl_log, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lbl_log, UI_HOR_RES - 20);
lv_obj_set_style_text_color(lbl_log, lv_color_hex(COL_INK), 0);
lv_obj_set_style_text_font(lbl_log, &lv_font_montserrat_14, 0);
lv_obj_set_pos(lbl_log, 10, 156);
const int bw = 72;
const int bh = 36;
const int y = UI_VER_RES - bh - 8;
btn_adv = make_btn(scr, "Stop Adv", COL_BTN_ACC, on_adv, bw, bh);
lbl_adv = lv_obj_get_child(btn_adv, 0);
lv_obj_set_pos(btn_adv, 8, y);
lv_obj_t *btn_echo = make_btn(scr, "Echo", COL_BTN, on_echo, bw, bh);
lv_obj_set_pos(btn_echo, 8 + bw + 6, y);
lv_obj_t *btn_ping = make_btn(scr, "Ping", COL_BTN_ACC, on_ping, bw, bh);
lv_obj_set_pos(btn_ping, 8 + 2 * (bw + 6), y);
lv_obj_t *btn_clear = make_btn(scr, "Clear", COL_BTN_WARN, on_clear, bw, bh);
lv_obj_set_pos(btn_clear, 8 + 3 * (bw + 6), y);
refresh_static();
refresh_state();
}
void ble_ui_tick() {
BleRxPacket pkt;
while (ble_core_poll_rx(&pkt)) {
char line[BLE_CORE_RX_MAX + 8];
snprintf(line, sizeof(line), "RX: %s", pkt.data);
append_log(line);
}
const uint32_t now = millis();
if (now - g_last_refresh_ms < 300) {
return;
}
g_last_refresh_ms = now;
if (ble_core_state() != g_last_state || ble_core_echo() != g_last_echo) {
refresh_state();
}
}

45
src/main.cpp Normal file
View File

@@ -0,0 +1,45 @@
#include <Arduino.h>
#include <lvgl.h>
#include "ble_core.h"
#include "ble_ui.h"
#include "pins.h"
#include "touch.h"
static constexpr uint32_t DRAW_BUF_SIZE =
(SCREEN_WIDTH * SCREEN_HEIGHT / 10 * (LV_COLOR_DEPTH / 8));
static uint32_t draw_buf[DRAW_BUF_SIZE / 4];
void setup() {
Serial.begin(460800);
delay(200);
Serial.println();
Serial.printf("BOOT_OK | LVGL %d.%d.%d | CYD BLE\n", lv_version_major(),
lv_version_minor(), lv_version_patch());
pinMode(TFT_BL_PIN, OUTPUT);
digitalWrite(TFT_BL_PIN, HIGH);
touch_init();
lv_init();
lv_display_t *disp =
lv_tft_espi_create(SCREEN_WIDTH, SCREEN_HEIGHT, draw_buf, sizeof(draw_buf));
lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_270);
lv_indev_t *indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, touch_read_cb);
lv_indev_set_display(indev, disp);
ble_core_begin();
ble_ui_create();
}
void loop() {
ble_core_loop();
ble_ui_tick();
lv_timer_handler();
lv_tick_inc(5);
delay(5);
}

98
src/touch.cpp Normal file
View File

@@ -0,0 +1,98 @@
#include "touch.h"
#include "pins.h"
#include <Arduino.h>
/**
* SoftSPI XPT2046(与 tftest 相同校准)
* BL↔TR 主对角线镜像修正,配合 LVGL 270° 旋转。
*/
static void soft_write(uint8_t data) {
for (int i = 7; i >= 0; --i) {
digitalWrite(CYD_TOUCH_MOSI, (data >> i) & 1);
digitalWrite(CYD_TOUCH_CLK, HIGH);
delayMicroseconds(2);
digitalWrite(CYD_TOUCH_CLK, LOW);
delayMicroseconds(2);
}
}
static uint16_t soft_read12() {
uint16_t v = 0;
digitalWrite(CYD_TOUCH_CLK, HIGH);
delayMicroseconds(2);
digitalWrite(CYD_TOUCH_CLK, LOW);
delayMicroseconds(2);
for (int i = 0; i < 12; ++i) {
digitalWrite(CYD_TOUCH_CLK, HIGH);
delayMicroseconds(2);
v = (uint16_t)((v << 1) | (digitalRead(CYD_TOUCH_MISO) ? 1u : 0u));
digitalWrite(CYD_TOUCH_CLK, LOW);
delayMicroseconds(2);
}
return v;
}
static uint16_t xpt_read(uint8_t cmd) {
digitalWrite(CYD_TOUCH_CS, LOW);
soft_write(cmd);
uint16_t v = soft_read12();
digitalWrite(CYD_TOUCH_CS, HIGH);
return v;
}
static int16_t clampi(int32_t v, int32_t lo, int32_t hi) {
if (v < lo) {
return (int16_t)lo;
}
if (v > hi) {
return (int16_t)hi;
}
return (int16_t)v;
}
void touch_init() {
pinMode(CYD_TOUCH_CS, OUTPUT);
pinMode(CYD_TOUCH_CLK, OUTPUT);
pinMode(CYD_TOUCH_MOSI, OUTPUT);
pinMode(CYD_TOUCH_MISO, INPUT);
pinMode(CYD_TOUCH_IRQ, INPUT);
digitalWrite(CYD_TOUCH_CS, HIGH);
digitalWrite(CYD_TOUCH_CLK, LOW);
}
void touch_read_cb(lv_indev_t * /*indev*/, lv_indev_data_t *data) {
uint16_t adc_x = xpt_read(0x90);
uint16_t adc_y = xpt_read(0xD0);
adc_x = (uint16_t)((adc_x + xpt_read(0x90)) / 2);
adc_y = (uint16_t)((adc_y + xpt_read(0xD0)) / 2);
const bool irq = (digitalRead(CYD_TOUCH_IRQ) == LOW);
const bool in_range =
(adc_x > 80 && adc_x < 4000 && adc_y > 80 && adc_y < 4000);
if (!in_range || (!irq && (adc_x < 200 || adc_y < 200))) {
data->state = LV_INDEV_STATE_RELEASED;
return;
}
const int16_t p_x = (int16_t)adc_x;
const int16_t p_y = (int16_t)(4095 - adc_y);
int16_t x = map(p_x, 200, 3700, 1, SCREEN_WIDTH);
int16_t y = map(p_y, 240, 3800, 1, SCREEN_HEIGHT);
x = clampi(x, 0, SCREEN_WIDTH - 1);
y = clampi(y, 0, SCREEN_HEIGHT - 1);
const int32_t old_ux = y;
const int32_t old_uy = (SCREEN_WIDTH - 1) - x;
int16_t ui_x = clampi((old_uy * (UI_HOR_RES - 1)) / (UI_VER_RES - 1), 0,
UI_HOR_RES - 1);
int16_t ui_y = clampi((old_ux * (UI_VER_RES - 1)) / (UI_HOR_RES - 1), 0,
UI_VER_RES - 1);
data->point.x = (int16_t)((SCREEN_WIDTH - 1) - ui_y);
data->point.y = ui_x;
data->state = LV_INDEV_STATE_PRESSED;
}