# 18 | 场景联动:智能电灯如何感知光线?(上) 你好,我是郭朝斌。 在上一讲,我们打造了自己的联网智能电灯,你可以通过手机小程序来控制它的打开和关闭,也就是实现远程控制。 其实,我们还可以进一步提高体验,让智能电灯可以基于环境的明暗来自动地打开和关闭。要做到这一点并不难,可以分为两个阶段,第一阶段是打造传感器设备来感知光照的强弱,判断出环境的明暗状态,第二阶段是创建一个场景联动,根据传感器的数值来控制智能电灯的状态。 这一讲,我先带你一步一步地实现第一阶段的工作(如有需要,你可以根据[这份文档](https://shimo.im/sheets/D3VVPdwcYRhhQRXh/MODOC)自行采购相关硬件)。 ## 第一步:通信技术 首先,我们为光照传感器设备选择通信技术。 因为光照传感器设备的部署位置比较灵活,不太可能像智能电灯一样连接房间里的电源线,所以我们要用一种比Wi-Fi功耗更低的通信技术。这样的话,就算使用电池供电,也可以长时间(一年以上)持续工作。 经过对比,我建议选择 BLE 低功耗蓝牙技术(关于通信技术的选择策略,你可以参考[第2讲](https://time.geekbang.org/column/article/306976))。随着智能手机的发展,蓝牙早已成为手机标配的通信技术,蓝牙芯片和协议栈的成熟度非常高,而且在设备的供应链方面,蓝牙芯片可以选择的供应商也非常多。 不过在正式开发之前,我还得为你补充说明一些BLE的相关知识。 BLE设备可以在4种模式下工作: 1. **广播模式**(Broadcaster),这里特指单纯的广播模式。这种模式下设备不可以被连接,只能够以一定的时间间隔把数据广播出来,供其他设备使用,比如手机扫描处理。蓝牙Beacon设备就是工作在这种模式。 2. **从机模式**(Peripheral),这种模式下设备仍然可以广播数据,同时也可以被连接。建立连接后,双方可以进行双向通信。比如你用手机连接一个具有蓝牙功能的体温计,这时体温计就是从机(Peripheral)。 3. **主机模式**(Central),这种模式下设备不进行广播,但是可以扫描周围的蓝牙广播包,发现其他设备,然后主动对这些设备发起连接。还是刚才那个例子,主动连接蓝牙体温计的手机就是主机(Central)角色。 4. **观察者模式**(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。 然后你应该就能看到下图所示的内容,并且可以进行交互。 ![](https://static001.geekbang.org/resource/image/e2/8b/e2a9a62bc472a7f41ddca0e5b6ca678b.png) ## 第四步:搭建光照传感器硬件电路 现在,我们开始基于NodeMCU搭建光照传感器的硬件电路。 首先,我们要准备好实验的材料: 1. NodeMCU(ESP32)开发板一个。注意区分芯片的具体型号。 2. 光照传感器模块一个 3. 杜邦线/跳线若干个 4. 面包板一个 然后,你可以按照我画的连线图来搭建出自己的电路。跟联网智能电灯的电路比起来,这个还是非常简单的。 ![](https://static001.geekbang.org/resource/image/ff/ab/ffcd6bb49b89e3d079fc1ca5c2dd18ab.png) 这里说明一下,在我的电路图中,光照传感器模块从左到右,管脚分别是光强度模拟信号输出管脚、电源地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("Light ADC value:",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。 2. Scan Response中 Manufacturer Specific Data (0xFF)含有小米公司识别码的广播包,识别码ID是0x038F。 其中,无论是在Advertising Data中,还是Scan Response中,均采用统一格式定义。 具体的广播报文格式定义,你可以参考下面的表格。 ![](https://static001.geekbang.org/resource/image/23/aa/23a2b5245717b21c4ce766bc13d69faa.jpg) 因为我们要为光照传感器增加广播光照强度数据的能力,所以主要关注[Object的定义](https://iot.mi.com/new/doc/embedded-development/ble/object-definition)。Object分为属性和事件两种,具体定义了设备数据的含义,比如体温计的温度、土壤的湿度等,数据格式如下表所示: ![](https://static001.geekbang.org/resource/image/a7/4e/a79e59c00b0ba726923910c8768f674e.jpg) 按照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("!h", 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("<3HB",uuid,fc,pid,fcnt)+mac+struct.pack("