<audio id="audio" title="18 | 场景联动：智能电灯如何感知光线？（上）" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/ff/1924745036eccd50c3a5ca0ec7dcccff.mp3"></audio>

你好，我是郭朝斌。

在上一讲，我们打造了自己的联网智能电灯，你可以通过手机小程序来控制它的打开和关闭，也就是实现远程控制。

其实，我们还可以进一步提高体验，让智能电灯可以基于环境的明暗来自动地打开和关闭。要做到这一点并不难，可以分为两个阶段，第一阶段是打造传感器设备来感知光照的强弱，判断出环境的明暗状态，第二阶段是创建一个场景联动，根据传感器的数值来控制智能电灯的状态。

这一讲，我先带你一步一步地实现第一阶段的工作（如有需要，你可以根据[这份文档](https://shimo.im/sheets/D3VVPdwcYRhhQRXh/MODOC)自行采购相关硬件）。

## 第一步：通信技术

首先，我们为光照传感器设备选择通信技术。

因为光照传感器设备的部署位置比较灵活，不太可能像智能电灯一样连接房间里的电源线，所以我们要用一种比Wi-Fi功耗更低的通信技术。这样的话，就算使用电池供电，也可以长时间（一年以上）持续工作。

经过对比，我建议选择 BLE 低功耗蓝牙技术（关于通信技术的选择策略，你可以参考[第2讲](https://time.geekbang.org/column/article/306976)）。随着智能手机的发展，蓝牙早已成为手机标配的通信技术，蓝牙芯片和协议栈的成熟度非常高，而且在设备的供应链方面，蓝牙芯片可以选择的供应商也非常多。

不过在正式开发之前，我还得为你补充说明一些BLE的相关知识。

BLE设备可以在4种模式下工作：

1. **广播模式**（Broadcaster），这里特指单纯的广播模式。这种模式下设备不可以被连接，只能够以一定的时间间隔把数据广播出来，供其他设备使用，比如手机扫描处理。蓝牙Beacon设备就是工作在这种模式。
1. **从机模式**（Peripheral），这种模式下设备仍然可以广播数据，同时也可以被连接。建立连接后，双方可以进行双向通信。比如你用手机连接一个具有蓝牙功能的体温计，这时体温计就是从机（Peripheral）。
1. **主机模式**（Central），这种模式下设备不进行广播，但是可以扫描周围的蓝牙广播包，发现其他设备，然后主动对这些设备发起连接。还是刚才那个例子，主动连接蓝牙体温计的手机就是主机（Central）角色。
1. **观察者模式**（Observer），这种模式下设备像主机模式一样，也不进行广播，而是扫描周围的蓝牙广播包，但是不同的地方是，它不会与从机设备建立连接。一般收集蓝牙设备广播包的网关就是在这种模式下工作的，它会将收集的广播数据通过网线、Wi-Fi或者4G等蜂窝网络上传到云平台。

在这一讲中，我们打造的光照传感器只需要提供光照强度数据就行了，并不需要进行双向通信，所以我们可以定义设备在广播模式下工作。

## 第二步：选择开发板

那么光照传感器设备要选择什么开发板呢？

我们在上一讲打造的联网智能电灯中使用的NodeMCU是基于**ESP8266芯片**的，相信你也注意到了，这款芯片并不支持低功耗蓝牙。

好在市场上还有一款基于**ESP32芯片**的NodeMCU开发板。[ESP32](https://www.espressif.com/zh-hans/products/socs/esp32/overview)是乐鑫科技出品的另一款性能优良且满足低功耗的物联网芯片，它同时支持Wi-Fi和低功率蓝牙通信技术，还有丰富的ADC接口。

更重要的是，MicroPython也支持ESP32芯片，这样我们就可以继续使用Python语言来开发了。

## 第三步：准备MicroPython环境

接下来，我们就在NodeMCU（ESP32）上安装MicroPython固件，准备Python程序的运行环境。

MicroPython官网已经为我们准备了编译好的固件文件，这省掉了我们在电脑上进行交叉编译的工作。你可以从[这个链接](http://micropython.org/download/esp32/) 中选择“Firmware with ESP-IDF v3.x”下面的“GENERIC”类别，直接下载最新版本的固件文件到电脑中。

然后，我们使用一根 USB 数据线，将 NodeMCU 开发板和电脑连接起来。USB数据线仍然选择一头是 USB-A 接口、另一头是 Micro-USB 接口，并且支持数据传输的完整线缆。具体细节，你可以再回顾[第16讲](https://time.geekbang.org/column/article/321652)中的相关内容。

我们使用esptool工具把这个固件烧录到NodeMCU开发板上。先在电脑终端上输入下面的命令，清空一下NodeMCU的Flash存储芯片。

```
esptool.py --chip esp32 --port /dev/cu.usbserial-0001 erase_flash

```

你可以从命令里看到，和之前智能电灯用的命令相比，这里增加了芯片信息“esp32”。另外，“--port”后面的串口设备名称，需要你替换为自己电脑上对应的名称。

成功擦除Flash之后，就执行下面的命令，将固件写入Flash芯片。

```
esptool.py --chip esp32 --port /dev/cu.usbserial-0001 --baud 460800 write_flash -z 0x1000 esp32-idf3-20200902-v1.13.bin

```

这时，我们使用电脑上的终端模拟器软件，比如 SecureCRT，通过串口协议连接上开发板，注意波特率（Baud rate）设置为 115200。

然后你应该就能看到下图所示的内容，并且可以进行交互。

<img src="https://static001.geekbang.org/resource/image/e2/8b/e2a9a62bc472a7f41ddca0e5b6ca678b.png" alt="">

## 第四步：搭建光照传感器硬件电路

现在，我们开始基于NodeMCU搭建光照传感器的硬件电路。

首先，我们要准备好实验的材料：

1. NodeMCU（ESP32）开发板一个。注意区分芯片的具体型号。
1. 光照传感器模块一个
1. 杜邦线/跳线若干个
1. 面包板一个

然后，你可以按照我画的连线图来搭建出自己的电路。跟联网智能电灯的电路比起来，这个还是非常简单的。

<img src="https://static001.geekbang.org/resource/image/ff/ab/ffcd6bb49b89e3d079fc1ca5c2dd18ab.png" alt="">

这里说明一下，在我的电路图中，光照传感器模块从左到右，管脚分别是光强度模拟信号输出管脚、电源地GND和电源正VCC管脚。你需要根据自己的传感器模块调整具体的连线。

我选择的是基于PT550环保型光敏二极管的光照传感器元器件，它的灵敏度更高，测量范围是0Lux～6000Lux。

Lux（勒克斯）是光照强度的单位，它和另一个概念Lumens（流明）是不同的。Lumens是指一个光源（比如电灯、投影仪）发出的光能力的总量，而Lux是指空间内一个位置接收到的光照的强度。

这个元器件通过信号管脚输出模拟量，我们读取NodeMCU ESP32的ADC模数转换器（ADC0，对应GPIO36）的数值，就可以得到光照强度。这个数值越大，表示光照强度越大。

因为ADC支持的最大位数是12bit，所以这个数值范围是0~4095之间。这里我们粗略地按照线性关系做一个转换。具体计算过程，你可以参考下面的代码：

```
from machine import ADC
from machine import Pin

class LightSensor():

    def __init__(self, pin):
        self.light = ADC(Pin(pin))

    def value(self):
        value = self.light.read()
        print(&quot;Light ADC value:&quot;,value)
        return int(value/4095*6000)

```

## 第五步：编写蓝牙程序

NodeMCU ESP32的固件已经集成了BLE的功能，我们可以直接在这个基础上进行软件的开发。这里我们需要给广播包数据定义一定的格式，让其他设备可以顺利地解析使用扫描到的数据。

那么怎么定义蓝牙广播包的格式呢？我们可以使用小米制定的[MiBeacon](https://iot.mi.com/new/doc/embedded-development/ble/ble-mibeacon.html)蓝牙协议。

**MiBeacon蓝牙协议**的广播包格式是基于BLE的GAP（Generic Access Profile）制定的。GAP控制了蓝牙的广播和连接，也就是控制了设备如何被发现，以及如何交互。

具体来说，GAP定义了两种方式来让设备广播数据：

一个是广播数据（Advertising Data payload），这个是必须的，数据长度是31个字节；

另一个是扫描回复数据（Scan Response payload），它基于蓝牙主机设备（比如手机）发出的扫描请求（Scan Request）来回复一些额外的信息。数据长度和广播数据一样。

（注意，蓝牙5.0中有扩展的广播数据，数据长度等特性与此不同，但这里不涉及，所以不再介绍。）

所以，只要含有以下指定信息的广播报文，就可以认为是符合MiBeacon蓝牙协议的。

1. Advertising Data中 Service Data (0x16) 含有Mi Service UUID的广播包，UUID是0xFE95。
1. Scan Response中 Manufacturer Specific Data (0xFF)含有小米公司识别码的广播包，识别码ID是0x038F。

其中，无论是在Advertising Data中，还是Scan Response中，均采用统一格式定义。

具体的广播报文格式定义，你可以参考下面的表格。

<img src="https://static001.geekbang.org/resource/image/23/aa/23a2b5245717b21c4ce766bc13d69faa.jpg" alt="">

因为我们要为光照传感器增加广播光照强度数据的能力，所以主要关注[Object的定义](https://iot.mi.com/new/doc/embedded-development/ble/object-definition)。Object分为属性和事件两种，具体定义了设备数据的含义，比如体温计的温度、土壤的湿度等，数据格式如下表所示：

<img src="https://static001.geekbang.org/resource/image/a7/4e/a79e59c00b0ba726923910c8768f674e.jpg" alt="">

按照MiBeacon的定义，光照传感器的Object ID是0x1007，数据长度3个字节，数值范围是0~120000之间。

我将代码贴在下面，供你参考。

```
#File：ble_lightsensor.py
import bluetooth
import struct
import time
from ble_advertising import advertising_payload

from micropython import const

_IRQ_CENTRAL_CONNECT = const(1)
_IRQ_CENTRAL_DISCONNECT = const(2)
_IRQ_GATTS_INDICATE_DONE = const(20)

_FLAG_READ = const(0x0002)
_FLAG_NOTIFY = const(0x0010)

_ADV_SERVICE_DATA_UUID = 0xFE95
_SERVICE_UUID_ENV_SENSE = 0x181A
_CHAR_UUID_AMBIENT_LIGHT = 'FEC66B35-937E-4938-9F8D-6E44BBD533EE'

# Service environmental sensing
_ENV_SENSE_UUID = bluetooth.UUID(_SERVICE_UUID_ENV_SENSE)
# Characteristic ambient light density
_AMBIENT_LIGHT_CHAR = (
    bluetooth.UUID(_CHAR_UUID_AMBIENT_LIGHT),
    _FLAG_READ | _FLAG_NOTIFY ,
)
_ENV_SENSE_SERVICE = (
    _ENV_SENSE_UUID,
    (_AMBIENT_LIGHT_CHAR,),
)

# https://specificationrefs.bluetooth.com/assigned-values/Appearance%20Values.pdf
_ADV_APPEARANCE_GENERIC_AMBIENT_LIGHT = const(1344)

class BLELightSensor:
    def __init__(self, ble, name='Nodemcu'):
        self._ble = ble
        self._ble.active(True)
        self._ble.irq(self._irq)
        ((self._handle,),) = self._ble.gatts_register_services((_ENV_SENSE_SERVICE,))
        self._connections = set()
        time.sleep_ms(500)
        self._payload = advertising_payload(
            name=name, services=[_ENV_SENSE_UUID], appearance=_ADV_APPEARANCE_GENERIC_AMBIENT_LIGHT
        )
        self._sd_adv = None
        self._advertise()

    def _irq(self, event, data):
        # Track connections so we can send notifications.
        if event == _IRQ_CENTRAL_CONNECT:
            conn_handle, _, _ = data
            self._connections.add(conn_handle)
        elif event == _IRQ_CENTRAL_DISCONNECT:
            conn_handle, _, _ = data
            self._connections.remove(conn_handle)
            # Start advertising again to allow a new connection.
            self._advertise()
        elif event == _IRQ_GATTS_INDICATE_DONE:
            conn_handle, value_handle, status = data

    def set_light(self, light_den, notify=False):
        self._ble.gatts_write(self._handle, struct.pack(&quot;!h&quot;, int(light_den)))
        self._sd_adv = self.build_mi_sdadv(light_den)
        self._advertise()
        if notify:
            for conn_handle in self._connections:
                if notify:
                    # Notify connected centrals.
                    self._ble.gatts_notify(conn_handle, self._handle)

    def build_mi_sdadv(self, density):
        
        uuid = 0xFE95
        fc = 0x0010
        pid = 0x0002
        fcnt = 0x01
        mac = self._ble.config('mac')
        objid = 0x1007
        objlen = 0x03
        objval = density

        service_data = struct.pack(&quot;&lt;3HB&quot;,uuid,fc,pid,fcnt)+mac+struct.pack(&quot;&lt;H2BH&quot;,objid,objlen,0,objval)
        print(&quot;Service Data:&quot;,service_data)
        
        return advertising_payload(service_data=service_data)
        
    def _advertise(self, interval_us=500000):
        self._ble.gap_advertise(interval_us, adv_data=self._payload)
        time.sleep_ms(100)

        print(&quot;sd_adv&quot;,self._sd_adv)
        if self._sd_adv is not None:
            print(&quot;sdddd_adv&quot;,self._sd_adv)
            self._ble.gap_advertise(interval_us, adv_data=self._sd_adv)

```

```
#File: main.py
from ble_lightsensor import BLELightSensor
from lightsensor import LightSensor
import time
import bluetooth

def main():
    ble = bluetooth.BLE()
    ble.active(True)
    ble_light = BLELightSensor(ble)

    light = LightSensor(36)
    light_density = light.value()
    i = 0

    while True:
        # Write every second, notify every 10 seconds.
        i = (i + 1) % 10
        ble_light.set_light(light_density, notify=i == 0)
        print(&quot;Light Lux:&quot;, light_density)

        light_density = light.value()
        time.sleep_ms(1000)

if __name__ == &quot;__main__&quot;:
    main()

```

## 第六步：验证光照传感器

到这里，我们已经完成了光照传感器设备的开发工作。那么怎么验证设备有没有正常工作呢？

我们可以通过手机上的蓝牙调试软件来扫描周围蓝牙设备，查看设备有没有蓝牙广播包输出，能不能跟手机正常交互。常用的软件有LightBlue、nRFConnect 和 BLEScanner，选择其中一个就行了。

比如我选择的是nRF Connect，打开之后，它会自动扫描周围的蓝牙广播包，将发现的设备以列表的形式展示。

如果周围蓝牙设备很多的话，为了方便发现自己的开发板，你可以点击列表上方的“No Filter”，选择将“Max.RSSI”打开。拖动其中的滑竿到合适的值，比如-50dBm，就可以过滤掉蓝牙信号强度比较弱（一般也是比较远）的设备。

<img src="https://static001.geekbang.org/resource/image/fb/54/fbe2863b3968e69e36c4f880714fba54.png" alt="">

下面是我的手机扫描到的基于NodeMCU开发板的蓝牙设备。

<img src="https://static001.geekbang.org/resource/image/90/fc/90569129dbf544c3a9da37f1e2fd84fc.png" alt="">

其中名称Nodemcu下面的就是广播包的具体数据。

到这里，我们就完成了光照传感器设备的开发工作。

## 小结

总结一下，在这一讲中，我介绍了光照传感器的开发过程，并且补充了低功耗蓝牙技术的相关知识。下面，我们回顾以下重点：

1. 对于无法连接电源线、需要灵活放置甚至经常移动的设备，低功耗蓝牙技术是合适的通信技术选择。
1. MiBeacon协议的广播包定义是基于BLE的GAP（Generic Access Profile）制定的，主要有广播数据（Advertising Data）和扫描回复数据（Scan Response）两种。其中广播数据中Service Data的UUID是0xFE95，扫描回复数据中Manufacturer Specific Data的厂家识别码是0x038F。
1. 在日常的蓝牙设备开发工作中，我们经常需要调试、测试蓝牙功能，这时你可以使用手机上的蓝牙调试软件来验证，比如LightBlue、nRFConnect 和 BLEScanner等。

不过，准备好光照传感器设备只是第一步，为了实现光照传感器和智能电灯的联动，我们还需要将光照传感器接入网络。这就需要借助蓝牙网关设备了，在下一讲中，我将基于树莓派讲解网关设备的开发过程。

## 思考题

最后，给你留一个思考题吧。

在这一讲的开头，我提到蓝牙设备除了广播数据的能力，还可以连接进行交互。在我提供的代码中其实也包含了一个可供连接获取数据的Service和Characteristic，你发现了吗？你知道这些是基于低功耗蓝牙中的什么Profile协议吗？

欢迎你在留言区写下自己的答案和我交流一下，也欢迎你将这一讲分享给你的朋友，一起讨论学习。
