30、综合实战(下):完成智能台灯App的蓝牙/WiFi控制部分,实现手机与硬件联动

好,上一章我们把光线传感器和PWM调光的底层逻辑打通了。现在,咱们来干点真正酷的事——让手机App和硬件“对话”。

说白了,就是你的手机能远程开关灯、调亮度,甚至根据环境光自动调节。我当年做第一个物联网项目时,看到手机上的滑块一拖,灯泡真的变亮了,那种感觉……嗯,比第一次跑通Hello World爽多了。

通信方案选型:蓝牙还是WiFi?

先别急着写代码。你得想清楚:用蓝牙还是WiFi?

我个人习惯这样判断:

  • 蓝牙(BLE):手机和设备一对一,距离近(10米内),功耗低。适合台灯这种场景——你坐在旁边,手机控制就行。
  • WiFi:可以远程控制,哪怕你人在公司也能关家里的灯。但功耗高,需要配网流程,开发复杂度也高一些。

我在项目中遇到过不少选型翻车的情况。有人做智能窗帘,非要上WiFi,结果用户说“我阳台没WiFi信号”……你想想看,多尴尬。

咱们这节课,以蓝牙BLE为例。因为入门简单,而且台灯场景下,手机离灯通常不超过5米。WiFi部分我会在最后给个扩展思路。

核心结论:台灯这种近距离、低功耗、实时控制场景,BLE是首选。WiFi适合需要远程访问或接入智能家居生态的场景。

蓝牙BLE通信架构

先画个图,让你对整个通信链路有个直观认识。

智能台灯BLE通信架构 手机App(GATT Client) 扫描 → 连接 → 发现服务 读写特征值 → 接收通知 Android BLE API BLE通信 ESP32(GATT Server) 广播 → 等待连接 处理读写请求 Arduino / ESP-IDF 硬件外设 光线传感器 PWM LED灯 GPIO控制 I2C/PWM 数据流示例 ① App发送亮度值(如 0-255)→ 写入特征值 ② ESP32收到数据 → 解析 → 调用 analogWrite(PWM) ③ 传感器数据 → ESP32读取 → 通过通知发送给App显示

看到没?手机是客户端(GATT Client),ESP32是服务端(GATT Server)。它们通过“特征值”来交换数据。你写一个值进去,硬件就执行一个动作;硬件上报一个值,App就收到通知。

硬件端代码:ESP32上的BLE服务

先搞定硬件端。我用的是ESP32 + Arduino框架。代码不长,但每个细节都关键。

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>

// 定义服务和特征值UUID
#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHAR_LIGHT_CTRL     "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define CHAR_SENSOR_DATA    "cba1d466-344c-4b0d-9b1e-6b3c3a1f2e8d"

BLECharacteristic *pLightChar;
BLECharacteristic *pSensorChar;

// PWM设置
const int ledPin = 2;  // GPIO2
int currentBrightness = 0;

// 回调:处理App发来的控制指令
class MyCallbacks : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic *pCharacteristic) {
    String value = pCharacteristic->getValue();
    if (value.length() > 0) {
      int brightness = value.toInt();
      brightness = constrain(brightness, 0, 255);
      currentBrightness = brightness;
      analogWrite(ledPin, brightness);
      Serial.println("亮度已更新: " + String(brightness));
    }
  }
};

void setup() {
  Serial.begin(115200);
  pinMode(ledPin, OUTPUT);
  
  // 初始化BLE
  BLEDevice::init("SmartLamp_ESP32");
  BLEServer *pServer = BLEDevice::createServer();
  BLEService *pService = pServer->createService(SERVICE_UUID);
  
  // 控制特征值(可写)
  pLightChar = pService->createCharacteristic(
    CHAR_LIGHT_CTRL,
    BLECharacteristic::PROPERTY_WRITE
  );
  pLightChar->setCallbacks(new MyCallbacks());
  
  // 传感器数据特征值(可通知)
  pSensorChar = pService->createCharacteristic(
    CHAR_SENSOR_DATA,
    BLECharacteristic::PROPERTY_NOTIFY
  );
  
  pService->start();
  BLEAdvertising *pAdvertising = pServer->getAdvertising();
  pAdvertising->start();
  
  Serial.println("BLE服务已启动,等待连接...");
}

void loop() {
  // 模拟读取光线传感器(实际项目中替换为I2C读取)
  int lux = analogRead(34);  // 假设接在GPIO34
  lux = map(lux, 0, 4095, 0, 1000);
  
  // 每2秒上报一次传感器数据
  static unsigned long lastTime = 0;
  if (millis() - lastTime > 2000) {
    String data = String(lux);
    pSensorChar->setValue(data.c_str());
    pSensorChar->notify();
    lastTime = millis();
  }
  
  delay(10);
}

个人经验:UUID别随便乱写。我早期项目里用过“1234”这种测试UUID,结果跟其他设备冲突,调试了一下午才发现。建议用在线UUID生成器,每次生成新的。

Android端:BLE扫描、连接与通信

Android端的代码稍微复杂一点。因为涉及权限、扫描、连接队列管理。我直接给你一个封装好的工具类,你拿去就能用。

// BLEManager.java - 核心管理类
public class BLEManager {
    private BluetoothAdapter bluetoothAdapter;
    private BluetoothLeScanner bleScanner;
    private BluetoothGatt bluetoothGatt;
    private BluetoothGattCharacteristic lightChar;
    private BluetoothGattCharacteristic sensorChar;
    
    private Context context;
    private boolean isScanning = false;
    
    // 回调接口
    public interface BLEListener {
        void onDeviceFound(BluetoothDevice device, int rssi);
        void onConnected();
        void onDisconnected();
        void onSensorDataReceived(int lux);
    }
    
    public BLEManager(Context context) {
        this.context = context;
        BluetoothManager bluetoothManager = 
            (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
        bluetoothAdapter = bluetoothManager.getAdapter();
        bleScanner = bluetoothAdapter.getBluetoothLeScanner();
    }
    
    // 开始扫描
    public void startScan(BLEListener listener) {
        if (isScanning) return;
        isScanning = true;
        
        ScanCallback scanCallback = new ScanCallback() {
            @Override
            public void onScanResult(int callbackType, ScanResult result) {
                BluetoothDevice device = result.getDevice();
                if (device.getName() != null && 
                    device.getName().equals("SmartLamp_ESP32")) {
                    listener.onDeviceFound(device, result.getRssi());
                }
            }
        };
        
        bleScanner.startScan(scanCallback);
    }
    
    // 连接设备
    public void connect(BluetoothDevice device, BLEListener listener) {
        bluetoothGatt = device.connectGatt(context, false, 
            new BluetoothGattCallback() {
                @Override
                public void onConnectionStateChange(BluetoothGatt gatt, 
                    int status, int newState) {
                    if (newState == BluetoothProfile.STATE_CONNECTED) {
                        gatt.discoverServices();
                        listener.onConnected();
                    } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                        listener.onDisconnected();
                    }
                }
                
                @Override
                public void onServicesDiscovered(BluetoothGatt gatt, int status) {
                    BluetoothGattService service = 
                        gatt.getService(UUID.fromString(SERVICE_UUID));
                    lightChar = service.getCharacteristic(
                        UUID.fromString(CHAR_LIGHT_CTRL));
                    sensorChar = service.getCharacteristic(
                        UUID.fromString(CHAR_SENSOR_DATA));
                    
                    // 开启传感器通知
                    gatt.setCharacteristicNotification(sensorChar, true);
                }
                
                @Override
                public void onCharacteristicChanged(BluetoothGatt gatt,
                    BluetoothGattCharacteristic characteristic) {
                    String data = characteristic.getStringValue(0);
                    int lux = Integer.parseInt(data.trim());
                    listener.onSensorDataReceived(lux);
                }
            });
    }
    
    // 发送亮度控制指令
    public void setBrightness(int brightness) {
        if (lightChar == null) return;
        brightness = Math.max(0, Math.min(255, brightness));
        lightChar.setValue(String.valueOf(brightness));
        bluetoothGatt.writeCharacteristic(lightChar);
    }
}

注意:Android 12以上需要动态申请 BLUETOOTH_SCAN、BLUETOOTH_CONNECT 权限。我踩过这个坑——在小米手机上扫描不到设备,折腾半天发现是权限弹窗被系统拦截了。记得在代码里处理权限回调。

UI层:控制面板的实现

UI我用了最简单的布局:一个SeekBar调亮度,一个TextView显示传感器数据。你可以在XML里这样写:

<!-- activity_main.xml -->
<LinearLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="24dp">
    
    <TextView
        android:text="智能台灯控制"
        android:textSize="22sp"
        android:textStyle="bold"
        android:layout_marginBottom="32dp"/>
    
    <TextView
        android:text="当前亮度"
        android:textSize="16sp"/>
    
    <SeekBar
        android:id="@+id/seekBar_brightness"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:max="255"
        android:progress="128"/>
    
    <TextView
        android:id="@+id/text_brightness_value"
        android:text="128"
        android:textSize="18sp"
        android:layout_marginTop="8dp"/>
    
    <TextView
        android:text="环境光传感器"
        android:textSize="16sp"
        android:layout_marginTop="32dp"/>
    
    <TextView
        android:id="@+id/text_sensor_lux"
        android:text="等待数据..."
        android:textSize="24sp"
        android:textColor="#FF9800"/>
    
    <Button
        android:id="@+id/btn_connect"
        android:text="连接设备"
        android:layout_marginTop="32dp"/>
    
</LinearLayout>

在Activity里,把SeekBar的滑动事件和BLE的写入绑定起来:

// MainActivity.java 关键部分
SeekBar seekBar = findViewById(R.id.seekBar_brightness);
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
    @Override
    public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
        if (fromUser) {
            bleManager.setBrightness(progress);
            textBrightness.setText(String.valueOf(progress));
        }
    }
    // 其他回调省略...
});

WiFi扩展思路

如果你想把台灯接入WiFi,思路其实类似:

  • ESP32作为HTTP Server:监听一个端口,App通过HTTP GET/POST发送指令。
  • MQTT协议:更适合生产环境。ESP32订阅一个Topic,App发布消息到该Topic。
  • 配网问题:可以用SmartConfig或蓝牙辅助配网。我建议用蓝牙配网——先通过BLE把WiFi密码发给ESP32,它再自动连接。用户体验好很多。

我的建议:先跑通蓝牙版本,再考虑WiFi。一口吃不成胖子。蓝牙版本从扫描到控制,整个流程走通也就半天时间。WiFi版本涉及路由器、防火墙、公网IP等问题,调试起来会多花几倍时间。

调试技巧与避坑指南

最后,分享几个我实战中总结的调试技巧:

  • 用nRF Connect App先测硬件:在写Android代码之前,先用这个App连接ESP32,手动读写特征值。确认硬件没问题,再写App端代码。这样能隔离问题。
  • 日志要打全:ESP32的Serial.print和Android的Log.d,能打多细打多细。我曾经因为少打一行日志,查了一个小时才发现是UUID写错了。
  • 注意MTU:BLE默认MTU是23字节,如果你要传大包(比如固件升级),记得协商MTU。控制台灯这种小数据,默认就够用。
  • 重连机制:App切到后台再切回来,蓝牙可能会断开。记得在onResume里检查连接状态,断开了自动重连。

避坑指南:我曾经在华为手机上遇到一个问题——扫描不到任何BLE设备。后来发现是华为的“手机管家”把蓝牙扫描权限给禁了。用户层面根本不知道。所以,建议在App启动时检查权限,并引导用户去设置里开启。

好了,蓝牙/WiFi控制部分就讲到这里。你现在应该能自己写一个App,跟ESP32台灯硬件联动了。从传感器读取到PWM调光,再到手机远程控制,整个闭环已经打通。剩下的,就是优化体验和加功能了。


公众号:蓝海资料掘金营,微信deep3321