项目和创意方向介绍
我使用M5Stack 的 M5Dial 结合ESP32-C3模块设计一个家庭物联网中的灯光控制器,可以用来实现家庭各处灯光的开关,明暗及色温调整。这个控制器基于wifi来控制全屋灯光,但当家里无线网络出现故障时,也可以通过有线的方式来对周围的灯光进行直接控制。
项目设计思路,实现方法,方案框图和原理图介绍
以上框图中列明了各种实现方法。实际做项目时需要做选择。首先灯光控制部分我选择使用了MOS驱动电路。原因是虽然对于我手上的单条24V灯带来说三极管也足够驱动,但参数上的冗余并不大;而选择TO-252封装的NMOS管,则完全不需要有参数上的担心。但电路上会稍微麻烦一点,需要设计一个mos管驱动电路。
远程方案处于方便融入现存物联网系统考虑,使用了MQTT来实现。
至于使用M5Dial有线控制部分,我选择了用M5Dial直接控制MOS驱动,而不是M5Dial控制ESP32-C3,再由ESP32-C3控制MOS驱动。
从上面的原理图可以看到,电路整体还是比较简单的。一部分是ESP32-C3的基本电路,使用了一个LDO作为电源,通过USB进行供电。另一部分是MOS管驱动电路。我使用一个上拉开漏的三极管来驱动MOS管。一般情况下MOS的gate电压上限在30V这样,我使用24V刚好不需要其他的供电电路,可以直接用电源来驱动MOS。
这里比较讲究的就是三极管上拉电阻的取值,如果这个电阻取值过大,则会使得MOS管开启速度过慢,使用PWM时开关损耗会大大增加,导致MOS发热;如果这个电阻过小,则会导致在关闭MOS管时流过三极管的电流过大,三极管异常发热。经过实际测试,在我当前24V的工况下,10K电阻是一个比较合适的大小。
另外,可以看到我在板子上还留了一个4孔接口,用来连接M5Dial,使用M5Dial驱动MOS管。这个接口我特意悬空了VCC引脚,也就是说M5Dial和板载的ESP32-C3是分别独立使用USB进行供电的,这样可以方便在使用M5Dial直接驱动MOS管时,关闭ESP32-C3,防止出现干扰。
设计中用到的指定厂商元器件及介绍
M5Dial是一款多功能的嵌入式开发板,配备1.28寸圆形TFT触摸屏,以M5StampS3为主控,内置旋转编码器,可精确记录旋钮位置。此外,板载RFID检测模块,RTC电路,板载蜂鸣器以及屏下按键用于设备互动和提醒唤醒等功能。供电方面,产品设计支持宽电压6-36V直流电输入,并预留了锂电池接口和充电电路,以提供不同需求。此外,预留的PORTA和PORTB接口可方便扩展I2C和GPIO设备。该产品适用于智能家居控制、物联网项目、智能穿戴、门禁、工业控制和教育创客项目等领域。
PCB设计介绍及遇到的问题和解决方法
PCB布线时需要注意24V大功率回路走线要宽一些,而且大功率部分尽量离MCU逻辑电路部分离远一点,避免大功率回路上的PWM引起耦合干扰。
关键代码及说明
这个项目在ESP32-C3上的代码比较简单,功能为作为HomeAssistant设备使用,可以通过MQTT来远程控制冷暖灯组。我使用了现成的ArduinoHA库来实现。这个库是基于Arduino的MQTT库PubSubClient来写的,使用的也是MQTT方法。用这个库而非直接使用PubSubClient的好处就是,创建的设备可以直接被HomeAssistant自动发现,而无需自己手动写配置payload或是在HomeAssistant手动添加设备。
完整的代码如下:
#include <WiFi.h>
#include <ArduinoHA.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
const char *ssid = "";
const char *password = "";
const char *mqtt_server = "";
const uint16_t mqtt_port = 1883;
const char *mqtt_username = "";
const char *mqtt_password = "";
const char *uniqueId = "LightStrip";
const char *state_file = "/state.txt";
const uint8_t warm_pin = 4;
const uint8_t cold_pin = 5;
WiFiClient client;
HADevice device(uniqueId);
HAMqtt mqtt(client, device);
JsonDocument state_dict;
// HALight::BrightnessFeature enables support for setting brightness of the light.
// HALight::ColorTemperatureFeature enables support for setting color temperature of the light.
// Both features are optional and you can remove them if they're not needed.
// "prettyLight" is unique ID of the light. You should define your own ID.
HALight light("prettyLight", HALight::BrightnessFeature | HALight::ColorTemperatureFeature);
void onStateCommand(bool state, HALight *sender) {
Serial.print("State: ");
Serial.println(state);
state_dict["state"] = state;
set_light();
state = state_dict["state"];
sender->setState(state); // report state back to the Home Assistant
save_json(state_file);
}
void onBrightnessCommand(uint8_t brightness, HALight *sender) {
Serial.print("Brightness: ");
Serial.println(brightness);
state_dict["brightness"] = brightness;
set_light();
brightness = state_dict["brightness"];
sender->setBrightness(brightness); // report brightness back to the Home Assistant
save_json(state_file);
}
void onColorTemperatureCommand(uint16_t temperature, HALight *sender) {
Serial.print("Color temperature: ");
Serial.println(temperature);
state_dict["temperature"] = temperature;
set_light();
temperature = state_dict["temperature"];
sender->setColorTemperature(temperature); // report color temperature back to the Home Assistant
save_json(state_file);
}
void set_light() {
const bool reverse = true;
float cold_val;
float warm_val;
if ((bool)state_dict["state"]) {
const float brightness_min = 3;
const float brightness_max = 255;
const float temperature_min = 153; // Cold
const float temperature_max = 500; // Warm
const float temperature_mid = (temperature_min + temperature_max) / 2;
if ((uint8_t)state_dict["brightness"] < brightness_min) {
state_dict["brightness"] = (uint8_t)brightness_min;
} else if ((uint8_t)state_dict["brightness"] > brightness_max) {
state_dict["brightness"] = (uint8_t)brightness_max;
}
if ((uint16_t)state_dict["temperature"] < temperature_min) {
state_dict["temperature"] = (uint16_t)temperature_min;
} else if ((uint16_t)state_dict["temperature"] > temperature_max) {
state_dict["temperature"] = (uint16_t)temperature_max;
}
float ratio = (uint16_t)state_dict["temperature"] / temperature_mid;
if (ratio > 1.0) {
warm_val = 100.0;
float ratio_extreme = temperature_max / temperature_mid;
cold_val = (ratio_extreme - ratio) / (ratio_extreme - 1.0) * 100.0;
} else {
cold_val = 100.0;
float ratio_extreme = temperature_min / temperature_mid;
warm_val = (ratio - ratio_extreme) / (1.0 - ratio_extreme) * 100.0;
}
warm_val = warm_val * (uint8_t)state_dict["brightness"] / brightness_max;
cold_val = cold_val * (uint8_t)state_dict["brightness"] / brightness_max;
} else {
warm_val = 0.0;
cold_val = 0.0;
}
Serial.print("Value: ");
Serial.print(warm_val);
Serial.print(" ");
Serial.println(cold_val);
uint8_t warm_pwm = warm_val / 100.0 * 255;
uint8_t cold_pwm = cold_val / 100.0 * 255;
if (reverse) {
warm_pwm = 255 - warm_pwm;
cold_pwm = 255 - cold_pwm;
}
Serial.print("PWM: ");
Serial.print(warm_pwm);
Serial.print(" ");
Serial.println(cold_pwm);
analogWrite(warm_pin, warm_pwm);
analogWrite(cold_pin, cold_pwm);
}
void save_json(const char *file_name) {
File file = LittleFS.open(file_name, FILE_WRITE);
if (!file) {
Serial.println("- failed to open file for writing");
}
if (!serializeJson(state_dict, file)) {
Serial.println(F("Failed to write to file"));
}
file.close();
}
void load_json(const char *file_name) {
File file = LittleFS.open(file_name);
if (!file) {
Serial.println("- failed to open file for reading");
}
if (deserializeJson(state_dict, file)) {
Serial.println(F("Failed to read from file"));
}
file.close();
}
void setup() {
Serial.begin(115200);
// Initialize Pin
pinMode(cold_pin, OUTPUT);
pinMode(warm_pin, OUTPUT);
// Get saved data
if (!LittleFS.begin(true)) {
Serial.println("LittleFS Mount Failed");
}
File file = LittleFS.open(state_file);
if (!file) {
state_dict["state"] = 0;
state_dict["brightness"] = 0;
state_dict["temperature"] = 240;
save_json(state_file);
} else {
load_json(state_file);
}
// configure light (optional)
light.setName("Bedroom");
// handle light states
light.onStateCommand(onStateCommand);
light.onBrightnessCommand(onBrightnessCommand); // optional
light.onColorTemperatureCommand(onColorTemperatureCommand); // optional
// Set initial state
light.setCurrentState((bool)state_dict["state"]);
light.setCurrentBrightness((uint8_t)state_dict["brightness"]);
light.setCurrentColorTemperature((uint16_t)state_dict["temperature"]);
set_light();
// Set WiFi
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
while (WiFi.waitForConnectResult() != WL_CONNECTED) {
Serial.println("Connection Failed! Rebooting...");
delay(1000);
ESP.restart();
}
// Start MQTT
mqtt.begin(mqtt_server, mqtt_port, mqtt_username, mqtt_password);
}
void loop() {
mqtt.loop();
// You can also change the state at runtime as shown below.
// This kind of logic can be used if you want to control your light using a button connected to the device.
// light.setState(true); // use any state you want
}
上传代码前,需要先设置好代码开头的WiFi和MQTT相关的字符串设置。代码里唯一需要强调讲解的是色温与亮度控制逻辑:由于灯带是两组灯珠,一组暖光一组冷光;而MQTT数据也是两个,一个是亮度,另一个是色温,因此我们需要用一个方法来吧色温和亮度转换为两组灯珠的输出。代码中我使用的逻辑是先假设亮度为100%,计算色温。如果色温偏暖光,则将暖光灯珠功率设置为100%,减少对应百分比的冷光灯珠输出;反之亦然。接着再考虑亮度,将两组灯珠上一步计算得到的输出按照亮度等比例下调,就可以完成计算。
下面是重头戏,M5Dial部分代码。我使用LVGL写了一个界面,同时增加了编码器和按键的驱动,让我们既可以通过触屏操作,也可以通过旋转编码器和按钮操作。由于项目相对较为复杂,我使用了vscode中的platformio来构建项目。
首先需要先配置项目的platformio.ini文件。我基于的配置是m5stack-stamps3板子的配置,因为M5Dial的核心其实就是StampS3。但这里有个坑需要注意,StampS3的USB接口是USB-CDC,但是在默认配置里却没有启用,还是使用的UART。因此我们需要手动加上一个build_flags。完整的配置如下:
[env:m5stack-stamps3]
platform = espressif32
board = m5stack-stamps3
framework = arduino
monitor_speed = 115200
build_flags =
-DARDUINO_USB_CDC_ON_BOOT
lib_deps =
SPI
Wire
lvgl/lvgl
moononournation/GFX Library for Arduino
https://github.com/mmMicky/TouchLib.git
dawidchyrzynski/home-assistant-integration
knolleary/PubSubClient
bblanchon/ArduinoJson
项目中我把每一个单独的功能都写到了单独的cpp和h文件中,这样比较方便理解修改。
为了体现出LVGL的可移植性,在设置所有元素大小位置时我都是用了相对于屏幕大小的方法来设置,这样就可以适配不同的分辨率及长宽比的屏幕:
void my_lv_obj_set_scale(lv_obj_t *obj, int32_t scale)
{
int32_t parent_size;
int32_t width = lv_obj_get_width(lv_obj_get_parent(obj));
int32_t height = lv_obj_get_height(lv_obj_get_parent(obj));
if (width > height)
{
parent_size = height;
}
else
{
parent_size = width;
}
lv_obj_set_size(obj, parent_size * scale / 100, parent_size * scale / 100);
}
可以看到,在M5Dial上显示效果如下:
而仅修改一下my_display中的屏幕驱动,别的都不变的情况下,把程序烧写到之前活动使用的ESP32-S3-BOX-Lite上,可以看到即使在分辨率和长宽比都不同的情况下,依旧可以得到很好的显示效果:
另外,考虑到实际使用时避免每次开关都需要调整亮度色温等参数,我使用了littleFS来保存每次调整后的状态。这样断电重启也会自动恢复到之前的参数。
void save_json(const char *file_name)
{
File file = LittleFS.open(file_name, FILE_WRITE);
if (!file)
{
Serial.println("Failed to open file for writing");
return;
}
if (!serializeJson(state_dict, file))
{
Serial.println(F("Failed to write to file"));
}
file.close();
}
void load_json(const char *file_name)
{
File file = LittleFS.open(file_name);
if (!file)
{
Serial.println("- failed to open file for reading");
}
if (deserializeJson(state_dict, file))
{
Serial.println(F("Failed to read from file"));
}
file.close();
}
void print_json()
{
char buffer[256];
serializeJson(state_dict, buffer);
Serial.println(buffer);
}
功能展示图及说明
首先测试使用M5Dial直接控制灯带的功能,我们通过数据线把M5Dial和灯板连接在一起,此时仅给灯板和M5Dial供电。
接下来我们测试一下无线功能,可以直接把M5Dial从板子上拆下来,并给ESP32-C3供上电,然后尝试用旋钮来控制。
当然,也可以直接使用任何设备通过HomeAssistant的网页开进行控制。而且在使用M5Dial控制时,网页上也会同步当前控制的情况。
心得体会
FastBond活动给了项目设计极大的灵活性,让我有机会可以实现平时只是想想但一直因为种种原因没有动手的创意。非常喜欢此类活动,希望这类活动以后可以多多举办。