华为云IoT+OpenHarmony的智能家居开发【华为云IoT+鸿蒙】华为云IoT+鸿蒙
一、选题说明
1. 选题为基于OpenHarmony的智能家居,应用场景为户用,受益人群为住户。
2. 开发的软件设备为智能门锁,储物精灵,软硬件开发都有的是光伏逆变器。
3. 解决的问题:
传统的智能家居:智能单品,需要手动加入场景,无网不能智能控制
创新的智能家居:空间智能,自发现后融入场景,分布式软总线控制
4. 关键功能点:
智能门锁:密码解锁,NFC解锁,数字管家控制,服务卡片控制
储物精灵:密码解锁,NFC解锁,防火帘控制,分布式软总线控制
逆变器:单相逆变,隔离拓扑,组件小型化,高转换率与低总谐波失真
二、竞赛开发平台
1. 操作系统:OpenHarmony 3.0
2. 开发软件:VS code(Deveco studio tool),DevEco Studio
3. 开发板:深开鸿KHDVK-3861B,润和DAYU200,润和hispark AI Camera
3. 关于环境:
操作系统:Ubuntu 编译构建:Python
包管理工具:HPM NPM 环境:Node.js
烧录软件:Hiburn USB串口驱动:CH341SER.exe
本地ssh:finalshell ssh文件对比:Beyond Conpare
4. 虚拟机
(1) 虚拟机环境
Ubuntu(华为的硬件开发一般都在此linux环境下进行)
虚拟机Vmware:下载之后使用上述提到的华为云中国镜像。
下载VS code的Linux版与OpenHarmony3.0源码。
(2)虚拟机环境:
将Ubuntu Shell环境修改为bash:ls -l /bin/sh
在下载VS code后下载华为硬件编制插件(Device tool)
(3) HB编译插件:
安装:python3 -m pip install --user ohos-build
变量:vim ~/.bashrc
复制粘贴到.bashrc的最后一行:export PATH=~/.local/bin:$PATH
更新变量:source ~/.bashrc
检查:pip install ohos-build
5. 逆变器的主要硬件选材:
(1)选材方案(选型依据)
Diode(二极管):高频检波电路,小型化,配对二极管不混组
Inductor(感应器):标称电感值,自共振频率(与互感值成正比)直流电阻(尽可能小),额定电流,直流重叠允许电流,温度上升允许电流。
Resistor(电阻器):贴片电阻,根据稳定性需求选择薄膜或厚膜
SPICE NMOS:封装尺寸,基本上封装越大越好,能承受的电流就越大;导通电压Vgs,尽量选择比实际电路中可提供的控制电压小的;导通电阻Rds,越小越好相应的导通电阻越小、分压越小、发热也越小。寄生电容Cgs,会影响mos的打开速度。寄生电容太大,方波会失真Rds越小,Cgs越大。
(2)主要选材
半导体选材:国产半导体RX65T125PS2A
电源IC选材:国产IC芯片ID7s625
DSP处理器:洞察到STM32系列软合了DSP处理器的功能。
三、方案详述
(一)储物精灵
(1)用户角度:先从用户角度考虑需求与如何使用,再从技术层面解析
(具体用户使用方法这里不多赘述,详细内容直接看下午开发内容)
(2)实现原理:(下文的步骤会详细介绍,在这里先介绍初期设想)
①关于Mqtt协议在华为云的打通(设备在线激活):使用mqttX或mqttfx。
②华为云:根据提示创建并获取密钥等信息,获取ClientID等身份识别信息,然后在云端的Topic(事件主题)中自定义订阅与发布,对产品进行定义。
③ AppGallery Connect网站:创建并注册HarmonyOS产品,根据提示流程。
④设备开发具体解析:每个设备都是一个子节点,实现了基于OpenHarmony设备的L0、L1、L2设备之间的互联互通。主控程序基于 OpenHarmony JS应用程序框架设计实现,并使用MQTT物联网通信协议接入华为云IOT平台,同时可将控制指令发送至华为云IOT平台,供云端处理。DAYU开发板(软件+硬件)具体实现为中控MQTT通信过程处于内核态驱动程序,JS应用通过发起接口调用后,进入用户态调用内核态接口的流程,并且JS应用会将所需要向云端发送的MQTT协议主题内容直接传入内核态,内核态不作数据处理和解析,直接将数据发布至云端,这样设计的目的是为了在添加设备的时候,仅需改变JS应用的数据结构,并不需要修改设备的代码,完成了解耦合。
NFC录入与记录:使用NFC扩展板录入,详细请见下方软总线设备通讯链接。
⑤智能识别对比:识别对象的数据库,这里的识别作为单一的照片识别。vuforia 的服务器制作该 target 的识别数据库,该数据库要下载并绑定工程到target。图片由摄像头获取视频逐帧比对。
(3)设备侧
第一步:网络连接 使设备接电后自动联网
我们会在代码中预置热点名称与密码
在创建包后新建mqtt_entry.c用于存放热点自连代码的地址:
/home/open/Downloads/code-v3.0-LTS/OpenHarmony/applications/sample/wifi-iot/app/mqtt_demo
{
int ret;
errno_t rc;
hi_wifi_assoc_request assoc_req = {0};
/* 拷贝SSID到assoc的req */
/* copy SSID to assoc_req */
rc = memcpy_s(assoc_req.ssid, HI_WIFI_MAX_SSID_LEN + 1, "rui666", 8); //热点名
/* WPA-PSK. CNcomment: 认证类型:WPA2-PSK.CNend */
if (rc != EOK) {
return -1;
}
//热点加密方式
assoc_req.auth = HI_WIFI_SECURITY_WPA2PSK;
memcpy(assoc_req.key, "88888888", 11); //热点的密码
ret = hi_wifi_sta_connect(&assoc_req);
if (ret != HISI_OK) {
return -1;
}
return 0;
} //预置热点名和密码 在设备通电后会自连
这里把原有的ability等量代换成了自发现热点。
*OpenHarmony_ability的碰一碰自发现与自配网见下述。
第二步:上报订阅与下发,在此包内创建main函数
/home/open/Downloads/code-v3.0-LTS/OpenHarmony/applications/sample/wifi-iot/app/mqtt_demo
void mqtt_callback(MessageData *msg_data)
{
size_t res_len = 0;
uint8_t *response_buf = NULL;
char topicname[45] = { "$crsp/" };
LOS_ASSERT(msg_data);
printf("topic %.*s receive a message\r\n", msg_data->topicName->lenstring.len, msg_data->topicName->lenstring.data);
printf("message is %.*s\r\n",
msg_data->message->payloadlen,
msg_data->message->payload);
}
int mqtt_connect(void)
{
int rc = 0;
NetworkInit(&n);
NetworkConnect(&n, "a161fa3144.iot-mqtts.cn-north-4.myhuaweicloud.com", 1883);
buf_size = 4096+1024;
onenet_mqtt_buf = (unsigned char *) malloc(buf_size);
onenet_mqtt_readbuf = (unsigned char *) malloc(buf_size);
if (!(onenet_mqtt_buf && onenet_mqtt_readbuf))
{
printf("No memory for MQTT client buffer!");
return -2;
}
MQTTClientInit(&mq_client, &n, 1000, onenet_mqtt_buf, buf_size, onenet_mqtt_readbuf, buf_size);
MQTTStartTask(&mq_client);
data.keepAliveInterval = 30;
data.cleansession = 1;
data.clientID.cstring = "61f6e729de9933029be57672_88888888_0_0_2022020905";
data.username.cstring = "61f6e729de9933029be57672_88888888";
data.password.cstring = "43872acc0b1e6aa7bf9e6a69f12aa9b1ebc07daffb67e18cf905c847a594f813";
data.cleansession = 1;
mq_client.defaultMessageHandler = mqtt_callback;
//连接服务器
rc = MQTTConnect(&mq_client, &data);
//订阅消息,设置回调函数
MQTTSubscribe(&mq_client, "porsche", 0, mqtt_callback);
while(1)
{
MQTTMessage message;
message.qos = QOS1;
message.retained = 0;
message.payload = (void *)"openharmony";
message.payloadlen = strlen("openharmony");
//上报
if (MQTTPublish(&mq_client, "hi3861", &message) < 0)
{
printf("MQTTPublish faild !\r\n");
}
IoTGpioSetOutputVal(9, 0); //9gpio 0 light on
usleep(1000000);
}
return 0;
}
第三步:储物精灵保险模式&舵机开门
舵机开锁:
int servoID =0;
void My_servo(uint8_t servoID,int angle)
{
int j=0;
int k=2000/200;
angle = k*angle;
for (j=0;j<5;j++){
IoTGpioSetOutputVal(servoID, 1);
hi_udelay(angle);
IoTGpioSetOutputVal(servoID, 1);
hi_udelay(20000-angle);
}
}
保险模式:
static int DealSetPassword(cJSON *objCmd)
{
int ret = -1;
char *pstr = NULL;
cJSON *objParas = NULL;
cJSON *objPara = NULL;
CommandParamSetPsk setLockPskParam;
memset(&setLockPskParam, 0x00, sizeof(CommandParamSetPsk));
if ((objParas = cJSON_GetObjectItem(objCmd, "paras")) == NULL) {
RaiseLog(LOG_LEVEL_ERR, "Paras not exist");
return ret;
}
if ((objPara = cJSON_GetObjectItem(objParas, "PskId")) != NULL) {
char *id = cJSON_GetStringValue(objPara); //密码标识(string型)
if (id == NULL || strlen(id) > LOCK_ID_LENGTH) {
RaiseLog(LOG_LEVEL_ERR, "check lock id failed!");
return -1;
}
strncpy(setLockPskParam.id, id, strlen(id));
} else {
return ret;
}
if ((objPara = cJSON_GetObjectItem(objParas, "Option")) != NULL) {
char *option = cJSON_GetStringValue(objPara);
printf("option = %c \n", *option); //三个命令(string型)
if (*option == 'A') {
setLockPskParam.option = OPTION_ADD; //新增密码
} else if (*option == 'U') {
setLockPskParam.option = OPTION_UPDATE; //更新密码
} else if (*option == 'D') {
setLockPskParam.option = OPTION_DELETE; //删除密码
} else {
RaiseLog(LOG_LEVEL_ERR, "no such option(%c)!", *option);
return -1;
}
} else {
return ret;
}
if ((objPara = cJSON_GetObjectItem(objParas, "LockPsk")) != NULL) {
char *psk = cJSON_GetStringValue(objPara);
if (psk == NULL || strlen(psk) > LOCK_PSK_LENGTH) {
RaiseLog(LOG_LEVEL_ERR, "check psk failed!");
return -1;
}
strncpy(setLockPskParam.password, psk, strlen(psk));
} else {
return ret;
}
ret = IotProfile_CommandCallback(CLOUD_COMMAND_SETPSK, &setLockPskParam);
return ret;
}
第四步:标注GPIO口
识别GPIO口与接入(这里要注意一个接的是正极一个是接地还有一个为信号传输口)
void mqtt_test(void)
{
IoTGpioInit(9);
IoTGpioSetDir(9, IOT_GPIO_DIR_OUT);
mqtt_connect();
}
第五步:吊起mqtt协议(build.gn版)
与主函数平行的Build.gn,吊起函数与第三方库的内容:
sources = [
"mqtt_test.c",
"mqtt_entry.c"
]
include_dirs = [
"//utils/native/lite/include",
"//kernel/liteos_m/components/cmsis/2.0",
"//base/iot_hardware/interfaces/kits/wifiiot_lite",
"//vendor/hisi/hi3861/hi3861/third_party/lwip_sack/include",
"//foundation/communication/interfaces/kits/wifi_lite/wifiservice",
"//third_party/pahomqtt/MQTTPacket/src",
"//third_party/pahomqtt/MQTTClient-C/src",
"//third_party/pahomqtt/MQTTClient-C/src/liteOS",
"//kernel/liteos_m/kal/cmsis",
"//base/iot_hardware/peripheral/interfaces/kits",
]
deps = [
"//third_party/pahomqtt:pahomqtt_static", //吊起MQTT协议
]
}
Build.gn:与APP并列的build.gn用于指出要编译的主函数,可以使用startup后面跟要编译的主包也可以直接features进行选中,在这里可以把不需要参与编译的项目通过#给注释掉。
在start_up里的builld.gn:
import("//build/lite/config/component/lite_component.gni")
lite_component("app") {
features = [
"mqtt_demo:mqtt_test", //标注主函数,指定位置编译
]
储物精灵Pro版(识别功能版):(使用第三方平台:Vuforia)
我们的原理就是上传画面到云端,然后逐帧分解比对(此功能目前还在完善)
(4)软件侧(偏向软件,但是还是嵌入式开发)
步骤一:接收服务器的存储代码
exports.i***ta2=function(req,res){
console.log("iot_data:",req)
const url = new URL("Get the URL provided by HUAWEI CLOUD"+req.url) //The address configured inside the forwarding destination
let properties = JSON.stringify(req.body.notify_data.body.services)
console.log("Store data:",properties)
let addArr = [properties]
var addSql = 'INSERT INTO sesnor_Record(properties) VALUES (?)'
var callBack = function(err,data){
console.log("error:"+err)
console.log("Property insertion result:"+JSON.stringify(data))
res.send(data)
}
sqlUtil.sqlContent(addSql,addArr,callBack)
}
步骤二:射频贴纸&复旦卡拉取本地方案
写入复旦卡请用第三方的软件,NFC射频贴纸使用应用调试助手(华为应用市场可下载)。
void RC522_Config ( void )
{
uint8_t ucStatusReturn; //Returns the status
uint8_t flag_station = 1; //Leave the flag bit of the function
while ( flag_station )
{
/* Seek cards (method: all in the range), the first search fails again, and when the search is successful, the card sequence is passed into the array ucArray_ID*/
if ( ( ucStatusReturn = PcdRequest ( PICC_REQALL, ucArray_ID ) ) != MI_OK )
ucStatusReturn = PcdRequest ( PICC_REQALL, ucArray_ID );
if ( ucStatusReturn == MI_OK )
{
/* An anti-collision operation in which the selected sequence of cards is passed into an array ucArray_ID */
if ( PcdAnticoll ( ucArray_ID ) == MI_OK )
{
if ( PcdSelect ( ucArray_ID ) == MI_OK )
{
printf ("\nRC522 is Ready!\n");
flag_station = 0;
}
}
}
}
}
步骤三:智能窗帘轨解决方案
因为马达可以无限前后方向的对窗帘轨进行拉动所以选择马达来进行拉动。另外要注意马达的功率来判断是否加入继电器,详情请移步源码仓(购买马达时要向供应商索要相关数据,同时向开发板供应商索要已公开的脚位置图)
static void RtcTimeUpdate(void)
{
extern int SntpGetRtcTime(int localTimeZone, struct tm *rtcTime);
struct tm rtcTime;
SntpGetRtcTime(CONFIG_LOCAL_TIMEZONE,&rtcTime);
RaiseLog(LOG_LEVEL_INFO, "Year:%d Month:%d Mday:%d Wday:%d Hour:%d Min:%d Sec:%d", \
rtcTime.tm_year + BASE_YEAR_OF_TIME_CALC, rtcTime.tm_mon + 1, rtcTime.tm_mday,\
rtcTime.tm_wday, rtcTime.tm_hour, rtcTime.tm_min, rtcTime.tm_sec);
if (rtcTime.tm_wday > 0) {
g_appController.curDay = rtcTime.tm_wday - 1;
} else {
g_appController.curDay = EN_SUNDAY;
}
g_appController.curSecondsInDay = rtcTime.tm_hour * CN_SECONDS_IN_HOUR + \
rtcTime.tm_min * CN_SECONDS_IN_MINUTE + rtcTime.tm_sec + 8; // add 8 ms offset
}
static uint32_t Time2Tick(uint32_t ms)
{
uint64_t ret;
ret = ((uint64_t)ms * osKernelGetTickFreq()) / CN_MINISECONDS_IN_SECOND;
return (uint32_t)ret;
}
#define CN_REACTION_TIME_SECONDS 1
static void BoardLedButtonCallbackF1(char *arg)
{
static uint32_t lastSec = 0;
uint32_t curSec = 0;
RaiseLog(LOG_LEVEL_INFO, "BUTTON PRESSED");
curSec = g_appController.curSecondsInDay;
if((curSec) < (lastSec + CN_REACTION_TIME_SECONDS)) {
RaiseLog(LOG_LEVEL_WARN, "Frequecy Pressed Button");
return;
}
lastSec = curSec;
g_appController.curtainStatus = CN_BOARD_SWITCH_ON;
g_appController.pwmLedDutyCycle = \
g_appController.pwmLedDutyCycle > 0 ? g_appController.pwmLedDutyCycle:CONFIG_LED_DUTYCYCLEDEFAULT;
osEventFlagsSet(g_appController.curtainEvent, CN_LAMP_EVENT_SETSTATUS);
return;
}
static void BoardLedButtonCallbackF2(char *arg)
{
uint32_t lastSec = 0;
uint32_t curSec = 0;
RaiseLog(LOG_LEVEL_INFO, "BUTTON PRESSED");
curSec = g_appController.curSecondsInDay;
if ((curSec) < (lastSec + CN_REACTION_TIME_SECONDS)) {
RaiseLog(LOG_LEVEL_WARN, "Frequecy Pressed Button");
return;
}
lastSec = curSec;
g_appController.curtainStatus = CN_BOARD_SWITCH_OFF;
osEventFlagsSet(g_appController.curtainEvent, CN_LAMP_EVENT_SETSTATUS);
return;
}
#define CURTAIN_MOTOR_GPIO_IDX 8
#define WIFI_IOT_IO_FUNC_GPIO_8_GPIO 0
#define WIFI_IOT_IO_FUNC_GPIO_14_GPIO 4
#define MOTOR_WORK_SECOND 6
static void E53SC1_MotorInit(void)
{
IoTGpioInit(CURTAIN_MOTOR_GPIO_IDX);
IoTGpioSetFunc(CURTAIN_MOTOR_GPIO_IDX, WIFI_IOT_IO_FUNC_GPIO_8_GPIO);
IoTGpioSetDir(CURTAIN_MOTOR_GPIO_IDX, IOT_GPIO_DIR_OUT); //设置GPIO_8为输出模式
return;
}
static void E53SC1_SetCurtainStatus(int curtainStatus)
{
if ((curtainStatus == CN_BOARD_CURTAIN_OPEN) || (curtainStatus == CN_BOARD_CURTAIN_CLOSE)) {
IoTGpioSetOutputVal(CURTAIN_MOTOR_GPIO_IDX, 1); //设置GPIO_8输出高电平打开电机
sleep(MOTOR_WORK_SECOND);
IoTGpioSetOutputVal(CURTAIN_MOTOR_GPIO_IDX, 0); //设置GPIO_8输出低电平关闭电机
}
return;
}
static void DataCollectAndReport(const void *arg)
{
(void)arg;
uint32_t curtainEvent;
uint32_t waitTicks;
waitTicks = Time2Tick(CONFIG_SENSOR_SAMPLE_CYCLE);
while (1) {
curtainEvent = osEventFlagsWait(g_appController.curtainEvent, CN_CURTAIN_EVENT_SETSTATUS, \
osFlagsWaitAny, waitTicks);
if (curtainEvent & CN_CURTAIN_EVENT_SETSTATUS) {
RaiseLog(LOG_LEVEL_INFO, "GetEvent:%08x", curtainEvent);
E53SC1_SetCurtainStatus(g_appController.curtainStatus);
}
(void) IotProfile_Report(g_appController.curtainStatus);
}
return;
}
static int UpdateShedule(CommandParamSetShedule *shedule)
{
if (shedule->num == 1 && shedule->day[0] == 0) { // set the one time schedule to current weekday
shedule->day[0] = (g_appController.curDay + 1);
}
switch (shedule->option) {
case 'A':
IOT_ScheduleAdd(shedule->scheduleID, shedule->day, shedule->num, shedule->startHour * CN_SECONDS_IN_HOUR +\
shedule->startMinute * CN_SECONDS_IN_MINUTE, shedule->duration, shedule->shedulecmd.cmd, 0);
break;
case 'U':
IOT_ScheduleUpdate(shedule->scheduleID, shedule->day, shedule->num, shedule->startHour * CN_SECONDS_IN_HOUR +\
shedule->startMinute * CN_SECONDS_IN_MINUTE, shedule->duration, shedule->shedulecmd.cmd, 0);
break;
case 'D':
IOT_ScheduleDelete(shedule->scheduleID, shedule->day, shedule->num, shedule->startHour * CN_SECONDS_IN_HOUR +\
shedule->startMinute * CN_SECONDS_IN_MINUTE, shedule->duration, shedule->shedulecmd.cmd, 0);
break;
default:
RaiseLog(LOG_LEVEL_ERR, "the schedule has no such option!\n");
break;
}
return 0;
}
void CurrentTimeCalcTimerHander(){
g_appController.curSecondsInDay ++;
}
#define TimeCalcTicks_NUMBER 100
#define CN_MINISECONDS_IN_1000MS 1000
static void CurtainShedule(void)
{
int startSecondInDay = 0;
int endSecondInDay = 0;
int settingCmd = 0;
int executeTaskTime = 0; // indicate the do something busy
osTimerId_t CurrentTimeCalc_id;
CurrentTimeCalc_id = osTimerNew(CurrentTimeCalcTimerHander, osTimerPeriodic, NULL, NULL);
osTimerStart(CurrentTimeCalc_id, TimeCalcTicks_NUMBER);
while (1) {
osDelay(Time2Tick(CN_MINISECONDS_IN_1000MS));
if (g_appController.curSecondsInDay >= CN_SECONS_IN_DAY) {
g_appController.curSecondsInDay = 0;
g_appController.curDay++;
if (g_appController.curDay >= EN_DAYALL) {
g_appController.curDay = EN_MONDAY;
}
IOT_ScheduleSetUpdate(1);
}
// check if we need do some task here
if (IOT_ScheduleIsUpdate(g_appController.curDay, g_appController.curSecondsInDay)) {
if (executeTaskTime > 0) {
executeTaskTime = 0;
if (g_appController.curtainStatus == CN_BOARD_CURTAIN_OPEN) {
g_appController.curtainStatus = CN_BOARD_CURTAIN_CLOSE;
osEventFlagsSet(g_appController.curtainEvent, CN_CURTAIN_EVENT_SETSTATUS);
}
}
startSecondInDay = IOT_ScheduleGetStartTime();
endSecondInDay = startSecondInDay + IOT_ScheduleGetDurationTime();
IOT_ScheduleGetCommand(&settingCmd, NULL);
}
RaiseLog(LOG_LEVEL_INFO, "start:%d end:%d cur:%d",startSecondInDay, endSecondInDay, g_appController.curSecondsInDay);
if ((endSecondInDay == startSecondInDay) && (g_appController.curSecondsInDay == endSecondInDay)) {
if (g_appController.curtainStatus != settingCmd) {
RaiseLog(LOG_LEVEL_INFO, "Triggering");
g_appController.curtainStatus = settingCmd;
osEventFlagsSet(g_appController.curtainEvent, CN_CURTAIN_EVENT_SETSTATUS);
}
IOT_ScheduleSetUpdate(1);
}
}
return;
}
int IotProfile_CommandCallback(int command, void *buf)
{
CommandParamSetShedule setSheduleParam;
CommandParamSetCurtain setCurtainParam;
//CommandParamSetDutyCycle setDutyCycleParam;
CLOUD_CommandType cmd = (CLOUD_CommandType)command;
if (cmd == CLOUD_COMMAND_SETCURTAIN_STATUS) {
setCurtainParam = *(CommandParamSetCurtain *)buf;
g_appController.curtainStatus = setCurtainParam.status;
RaiseLog(LOG_LEVEL_INFO, "setCurtainParam.status:%d\r\n", setCurtainParam.status);
osEventFlagsSet(g_appController.curtainEvent, CN_LAMP_EVENT_SETSTATUS);
return 0;
} else if (cmd == CLOUD_COMMAND_SETSHEDULE) {
setSheduleParam = *(CommandParamSetShedule *)buf;
RaiseLog(LOG_LEVEL_INFO, "setshedule:day:%d hour:%d minute:%d duration:%d \r\n", \
setSheduleParam.day,setSheduleParam.startHour,setSheduleParam.startMinute, setSheduleParam.duration);
return UpdateShedule(&setSheduleParam);
}
return -1;
}
static int IotWifiInfo_get(char *ssid, int id_size, char *pwd, int pd_size)
{
int retval = UtilsGetValue(SID_KEY, ssid, id_size);
if (retval <= 0) {
RaiseLog(LOG_LEVEL_ERR, "no such ssid stored! \n");
return 0;
}
if ( UtilsGetValue(PWD_KEY, pwd, pd_size) < 0) {
RaiseLog(LOG_LEVEL_INFO, "ssid(%s) no password stored! \n", ssid);
} else {
RaiseLog(LOG_LEVEL_INFO, "ssid : %s, pwd : %s! \n", ssid, pwd);
}
return 1;
}
static void IotWifiInfo_set(char *ssid, char *pwd)
{
if (UtilsSetValue(SID_KEY, ssid) != 0) {
RaiseLog(LOG_LEVEL_ERR, "store ssid failed! \n");
return;
}
if (UtilsSetValue(PWD_KEY, pwd) != 0) {
RaiseLog(LOG_LEVEL_ERR, "store password failed! \n");
UtilsDeleteValue(SID_KEY);
return;
}
RaiseLog(LOG_LEVEL_INFO, "store password success! \n");
}
static void IotMainTaskEntry(const void *arg)
{
osThreadAttr_t attr;
NfcInfo nfcInfo;
(void)arg;
char ssid[BUFF_SIZE] = {0};
char pwd[BUFF_SIZE] = {0};
int ret = 0;
g_appController.pwmLedDutyCycle = CONFIG_LED_DUTYCYCLEDEFAULT;
BOARD_InitPwmLed();
BOARD_InitWifi();
E53SC1_MotorInit();
IOT_ScheduleInit();
ret = Board_IsButtonPressedF2();
osDelay(MAIN_TASK_DELAY_TICKS);
LedFlashFrequencySet(CONFIG_FLASHLED_FRENETCONFIG);
nfcInfo.deviceID = "6136ceba0ad1ed02866fa3b2_Curtain01";
nfcInfo.devicePWD = "12345678";
if (ret) {
RaiseLog(LOG_LEVEL_INFO, "Netconfig Button has pressed! \n");
if (BOARD_NAN_NetCfgStartConfig(SOFTAP_NAME, ssid, sizeof(ssid), pwd, sizeof(pwd)) < 0) {
RaiseLog(LOG_LEVEL_ERR, "BOARD_NetCfgStartConfig failed! \n");
return;
} else {
ret = AFTER_NETCFG_ACTION;
}
} else {
ret = IotWifiInfo_get(ssid, sizeof(ssid), pwd, sizeof(pwd));
if (ret == 0) {
if (BOARD_NAN_NetCfgStartConfig(SOFTAP_NAME, ssid, sizeof(ssid), pwd, sizeof(pwd)) < 0) {
RaiseLog(LOG_LEVEL_ERR, "BOARD_NetCfgStartConfig failed! \n");
return;
} else {
ret = AFTER_NETCFG_ACTION;
}
}
}
LedFlashFrequencySet(CONFIG_FLASHLED_FREWIFI);
if (BOARD_ConnectWifi(ssid, pwd) != 0) {
RaiseLog(LOG_LEVEL_ERR, "BOARD_ConnectWifi failed! \n");
if (ret == AFTER_NETCFG_ACTION) {
NotifyNetCfgResult(NETCFG_DEV_INFO_INVALID);
}
hi_hard_reboot(HI_SYS_REBOOT_CAUSE_CMD);
return;
}
if (ret == AFTER_NETCFG_ACTION) {
RaiseLog(LOG_LEVEL_DEBUG, "Connect wifi success ! \n");
NotifyNetCfgResult(NETCFG_OK);
osDelay(MAIN_TASK_DELAY_TICKS);
RaiseLog(LOG_LEVEL_DEBUG, "StopNetCfg wifi success ! \n");
StopNetCfg();
IotWifiInfo_set(ssid, pwd);
}
LedFlashFrequencySet(CONFIG_FLASHLED_FRECLOUD);
RtcTimeUpdate();
if (CLOUD_Init() != 0) {
return;
}
if (CLOUD_Connect(nfcInfo.deviceID, nfcInfo.devicePWD, \
CONFIG_CLOUD_DEFAULT_SERVERIP, CONFIG_CLOUD_DEFAULT_SERVERPORT) != 0) {
return;
}
LedFlashFrequencySet(CONFIG_FLASHLED_WORKSWELL);
attr.attr_bits = 0U;
attr.cb_mem = NULL;
attr.cb_size = 0U;
attr.stack_mem = NULL;
attr.stack_size = CONFIG_TASK_DEFAULT_STACKSIZE;
attr.priority = CONFIG_TASK_DEFAULT_PRIOR;
attr.name = "DataCollectAndReport";
if (osThreadNew((osThreadFunc_t)DataCollectAndReport, NULL, (const osThreadAttr_t *)&attr) == NULL) {
return;
}
attr.name = "CurtainShedule";
if (osThreadNew((osThreadFunc_t)CurtainShedule, NULL, (const osThreadAttr_t *)&attr) == NULL) {
return;
}
return;
}
static void IotMainEntry(void)
{
osThreadAttr_t attr;
RaiseLog(LOG_LEVEL_INFO, "DATA:%s Time:%s \r\n",__FUNCTION__, __DATE__, __TIME__);
g_appController.curtainEvent = osEventFlagsNew(NULL);
if ( g_appController.curtainEvent == NULL) {
return;
}
// Create the IoT Main task
attr.attr_bits = 0U;
attr.cb_mem = NULL;
attr.cb_size = 0U;
attr.stack_mem = NULL;
attr.stack_size = CONFIG_TASK_MAIN_STACKSIZE;
attr.priority = CONFIG_TASK_MAIN_PRIOR;
attr.name = "IoTMain";
(void) osThreadNew((osThreadFunc_t)IotMainTaskEntry, NULL, (const osThreadAttr_t *)&attr);
return;
}
步骤四:无感配网解决方案(重要ability)
OpenHarmony设备之间的信息传递利用特有的NAN协议实现。利用手机和智能设备之间的WiFi 感知订阅、发布能力,实现了数字管家与设备之间的互联。在完成设备间的认证和响应后,即可发送相关配网数据。同时还支持与常规SoftAP配网方式共存。
相关代码:
teamX/common/iot_wifi/libs/libhilinkadapter_3861.a // 无感配网相关库文件
teamX/common/iot_wifi/libs/libnetcfgdevicesdk.a // 无感配网相关库文件
teamX/common/inc/iot_netcfg_nan.h
teamX/common/inc/network_config_service.h // 无感配网相关头文件
teamX/common/iot_wifi/iot_wifi.c // 相关联网接口
teamX/common/iot_wifi/iot_netcfg_nan.c // 无感配网相关实现
数字管家可以在gitee下载源码,在下载的team_X中查看详细介绍
步骤五:第三方平台接入
储物精灵Pro版(识别功能版):(使用第三方平台:Vuforia)
我们的原理就是上传画面到云端,然后逐帧分解比对(此功能目前还在完善)
第六步:实时摄像功能与智能检测光照值功能(正在实验中)
int GetLightAverageVal(unsigned char cnt)
{
unsigned short readVal = 0;
unsigned int totalVal = 0, totalCnt = 0;
for(unsigned char i=0; i<cnt; i++)
{
if(LightSensorVal(&readVal) == IOT_SUCCESS)
{
totalVal += readVal;
totalCnt++;
}
usleep(50000);
}
return (totalVal/totalCnt);
}
enum ENV_LIGHT_STATE GetEnvLightState(void)
{
enum ENV_LIGHT_STATE lightState = LIGHT_DAY;
int lightVal = GetLightAverageVal(5);
if(lightVal > ENV_LIGHT_LEVEL_LOWEST)
{
lightState = LIGHT_NIGHT;
}
else if(lightVal > ENV_LIGHT_LEVEL_LOW)
{
lightState = LIGHT_DUSK;
}
else
{
lightState = LIGHT_DAY;
}
return lightState;
}
第七步:分布式检索功能(实验中)
传统的分布式使用的是Elasticsearch进行,鉴于OpenHarmony能力所以需要开发出对口的ability。
isCreateIfMissing() //分布式数据库创建、打开、关闭和删除
setCreateIfMissing(boolean isCreateIfMissing) //数据库不存在时是否创建
isEncrypt() //获取数据库是否加密
setEncrypt(boolean isEncrypt) //设置数据库是否加密
getStoreType() //获取分布式数据库的类型
setStoreType(KvStoreType storeType) //设置分布式数据库的类型
KvStoreType.DEVICE_COLLABORATION //设备协同分布式数据库类型
KvStoreType.SINGLE_VERSION //单版本分布式数据库类型
getKvStore(Options options, String storeId) //根据Options配置创建和打开标识符为 storeId 的分布式数据库
closeKvStore(KvStore kvStore) //关闭分布式数据库
getStoreId() //分布式数据增、删、改、查。
subscribe(SubscribeType subscribeType, KvStoreObserver observer) //订阅
sync(List<String> deviceIdList, SyncMode mode) 数据同步
开发说明:(包括OH分布式文件)
1. 构造分布式数据库管理类(创建 KvManagerConfig 对象)
Context context;
...
KvManagerConfig config = new KvManagerConfig(context);
KvManager kvManager =
KvManagerFactory.getInstance().createKvManager(config);
2. 获取/创建单版本分布式数据库(声明需要创建的单版本分布式数据库ID说明)
Options CREATE = new Options();
CREATE.setCreateIfMissing(true).setEncrypt(false).setKvStoreType(KvStoreType.SINGLE_VERSION);
String storeID = "testApp";
SingleKvStore singleKvStore = kvManager.getKvStore(CREATE, storeID);
3. 订阅分布式数据更改(客户端需要实现KvStoreObserver接口&结构并注册KvStoreObserver实例)
class KvStoreObserverClient implements KvStoreObserver() {
public void onChange(ChangeNotification notification) {
List<Entry> insertEntries = notification.getInsertEntries();
List<Entry> updateEntries = notification.getUpdateEntries();
List<Entry> deleteEntries = notification.getDeleteEntries();
}
}
KvStoreObserver kvStoreObserverClient = new KvStoreObserverClient();
singleKvStore.subscribe(SubscribeType.SUBSCRIBE_TYPE_ALL, kvStoreObserverClient);
4. 构造需要写入单版本分布式数据库的Key和Value(将键值数据写入单版本分布式数据库)
String key = "todayWeather";
String value = "Sunny";
singleKvStore.putString(key, value);
5. 构造需要从单版本分布式数据库快照中查询的Key(数据取自单版本分布式数据库快照)
String key = "todayWeather";String value = singleKvStore.getString(key);
6. 获取设备列表与同步数据(PUSH_ONLY)
List<DeviceInfo> deviceInfoList = kvManager.getConnectedDevicesInfo(DeviceFilterStrategy.NO_FILTER);
List<String> deviceIdList = new ArrayList<>();
for (DeviceInfo deviceInfo : deviceInfoList) {
deviceIdList.add(deviceInfo.getId());
}
singleKvStore.sync(deviceIdList, SyncMode.PUSH_ONLY);
7. 首先get到设备数据交换权限
ohos.permission.DISTRIBUTED_DATASYNC
requestPermissionsFromUser(new String[]{"ohos.permission.DISTRIBUTED_DATASYNC"}, 0);
//然后在AbilitySlice中声明数据库并使用即可,这里不多赘述
9. 关于API的开放能力请详见官方文档,这里不再赘述。
10. 怼相关接口(正在实验的内容)
SearchAbility searchAbility = new SearchAbility(context);
CountDownLatch lock = new CountDownLatch(1);
searchAbility.connect(new ServiceConnectCallback() {
@Override
public void onConnect() {
lock.countDown();
}
@Override
public void onDisconnect() {
}
});
lock.await(3000, TimeUnit.MILLISECONDS);
11. 设置搜索属性与插入索引和重构查询等将会在下一次提交中进行补充。
(二)智能门锁
(上面的储物精灵源码也包括智能门锁的功能实现,这里补充介绍开发)
1. 环境搭建:
(1)需要手动配置在deveco tool里的用户组件
(2)接舵机的Gpio口
这里要注意一个接的是正极一个是接地还有一个为信号传输口
(3) 云端配置
首先在华为云官方获取Client ID等身份识别信息,然后在云端的Topic中自定义订阅与发布。
在初次开发时可以使用MQTTX软件进行了命令的订阅与下发实验,显示在线成功接收到上报和订阅的消息。
这样华为云的配置就成功了
如有需要还要进行产品定义与多功能的增加与实验
2.关于编译:
(1) 在VS code编译
点击build就可以生成json文件啦,编译成功后upload就可以直接烧录进去。(注意:在编译之后如果要再编译必须点击clean以删除build产生的json文件避免报错)
(2)在Ubuntu通过命令行编译
hb set //这是用于产生json文件的
hb build //这是用于编译的,编译后则会在源码的out文件夹中产生bin文件
hb clean //在build一次以后如果如果要再build那就必须进行此命令来删除json文件
在build成功后开发者就会发现在源码中的out文件夹中看到allinone.bin,然后发送到windows下使用Hiburn进行烧录即可(波特兰最大3000000,否则会烧坏板子)下图为HiBurn的配置方法,点击Connect即可烧录。
3.碰一碰卡片(原子化服务)
数字管家需要通过在APPGallery Connect中创建项目后添加应用从而获取Json文件,然后放在码云中下在的DistSchedule\netconfig\src\main\resources中。然后按照文档开发UI界面,点击构建的Generate Key and CSR创建用户名与密钥进行签名。
用户操作界面:在slice目录下新建 xxxSlice.java文件,通过addActionRoute方法为此AbilitySlice配置一条路由规则,并且在在应用配置文件(config.json)中注册。在resources/base/layout下新建对应xml布局文件,在上述两个文件中编写相应的UI。
数字管家数据处理:从slice获取deviceId:在onStart中通过调用DeviceID等,获取设备的名称等方便数字管家识别设备。从slice页面获取状态:开关锁可以直接调用intent.getBooleanParam来确定是进行开关锁还是对门锁的日程进行编排。
编写设备控制命令的解析:在CommandUtil中根据具体设备定义profile,来新增获取命令和解析命令的方法,用于设备在本地调用sendCommand来发送命令和解析。
配置设备端信息:在DeviceData的initData中,根据设备ProductID添加设备图片ID、跳转的action参数和解析方法,配置完成后设备列表页、用户页面等都能通过该配置进行图片加载、路由跳转和解析。
最后进行接口对接与NFC写入就可以了(通过应用调试助手写入NFC识别详细用于快速让手机识别到设备从而吊起数字管家实现鸿蒙的Ability)
(三)逆变器
1. 拓扑图
设计的单相逆变器,拥有隔离拓扑,通过控制GaN(HEMT)的高频开关实现逆变
关于桥臂:两个半桥产生中性点电压,另外两个半桥产生线电压,最后一个半桥作为有源滤波器。
2.现在STM32f407兼容了OpenHarmony 3.2 bata版,因为f4系列软合了dsp处理所以无需另外使用dsp从处理器。考虑到尽量减少直流侧输入电流纹波,输出的正弦波尽可能的平滑与减小总谐波失真,设计了一种并联有源滤波器,它比在输入端使用批量电容更有效地补偿纹波。
3.考虑到大部分EDA的元件库原件都不全,我在kicad按照厂家提供的数据手册画了个原件,并按照例出的参数进行了标注。
4. 关于电流与电压的总谐波失真等:有源滤波器工作在更高的电压变化下将相应的能量存储在陶瓷电容器中,陶瓷电容器的电容随着电压的降低而增加。通过算法保持Vin稳定同时允许有源滤波器产生大的波纹。输出电流结合电磁屏蔽的开环霍尔传感器形成非常紧凑的测量装置提供电流解耦并降低对共模和寄生感应噪声的敏感性。特定的GaN控制调制降低了滤波器电感中的电流可以在不达到饱和水平的情况下降低其核心尺寸。
5. 关于硬件选材:在上文的 二.竞赛开发平台 的逆变器中有介绍
6.通讯部分
(1)分布式软总线
基于UDP的coap协议,OpenHarmony特有分布式软总线。
编程步骤: 1.创建socket;
2.设置socket属性,用函数setsockopt();
3.绑定IP地址、端口等信息到socket上,用函数bind();
4.循环接收/发送数据,用函数recvfrom&sendto;
5.关闭网络连接。
创建一个socket,无论是客户端还是服务器端都需要创建一个socket。该函数返回socket文件描述符,类似于文件描述符。socket是一个结构体,被创建在内核中。
class UdpClient {
private DatagramSocket client;
public String sendAndReceive(String ip, int port, String msg) {
String responseMsg = "";
try {
//Create a client-side DatagramSocket object without having to pass in addresses and objects
client = new DatagramSocket();
byte[] sendBytes = msg.getBytes();
//Encapsulates the address of the destination to be sent
InetAddress address = InetAddress.getByName(ip);
//Encapsulates the object to send the DatagramPacket
DatagramPacket sendPacket = new DatagramPacket(sendBytes,sendBytes.length,address,port);
try {
//sent Data
client.send(sendPacket);
}catch (Exception e){
// e.printStackTrace();
}
byte[] responseBytes = new byte[2048];
//Create a DatagramPacket object for the response information
DatagramPacket responsePacket = new DatagramPacket(responseBytes,responseBytes.length);
try {
//Waiting for the response information, as on the server side, the client blocks at this step until it receives a packet
client.receive(responsePacket);
}catch (Exception e){
// e.printStackTrace();
}
//Parse the packet contents
responseMsg = new String(responsePacket.getData(),0,responsePacket.getLength());
}catch (Exception e){
// e.printStackTrace();
}finally {
//Close the client
if(client != null){
client.close();
client = null;
}
}
return responseMsg;
}
}
DatagramSocket类代表一个发送和接收数据包的插座该类是遵循 UDP协议 实现的一个Socket类
#define _PROT_ 8800 //UDP server port number
#define _SERVER_IP_ "666.66.66.666"
#define TCP_BACKLOG 5
#define IP_LEN 16
#define WIFI_SSID "rui666" //WiFi name
#define WIFI_PASSWORD "1145141919810" //WIFI oassword
开发板的IP与端口号
public void performClassification() {
int res = classifier.getResult(accelMeasurements, gyroMeasurements);
TaskDispatcher uiTaskDispatcher = this.getContext().getUITaskDispatcher();
String lab = classes[res];
result = lab;
TaskDispatcher globalTaskDispatcher = getContext().getGlobalTaskDispatcher(TaskPriority.DEFAULT);
globalTaskDispatcher.asyncDispatch(new Runnable() {
public void run() {
HiLog.warn(label, udpClient.sendAndReceive("666.66.66.666", 8800, result));
}
});
相关参数意义(注意要手搓的定义内容):
sin_family //Refers to protocol families, which can only be AF_INET in socket programming
sin_port //Storage port number (using network byte order)
sin_addr //Store the IP address and use the in_addr this data structure
sin_zero //Empty bytes are reserved in order to keep sockaddr and sockaddr_in two data structures the same size
fd //socket
buf //UDP datagram buffer (contains data to be sent)
len //The length of the UDP datagram
flags //Invocation operation mode (typically set to 0)
addr //A struct that points to the host address information that receives the data (type conversion required sockaddr_in)
alen //The length of the structure referred to by addr
nfds //Represents the range of all file descriptors in a collection
readfds //Select monitors a collection of readable file handles、
writefds //A collection of writable file handles that select monitors
exceptfds //A collection of exception file handles that select monitors
timeout //The timeout end time of select() this time, NULL means permanent wait
测试客户端的成功方法:通过UDP软件进行相关的发送与接收,并查看打印信息。因为与下文介绍的MQTTX软件使用原理差不多所以这里不多赘述。
(2) MQTT
Mqtt是用于设备与服务器通讯的一种协议,使设备可以上报订阅下发信息。需要下载此协议并存放在thirdparty(第三方库),并在头文件中吊起。
从开发板厂商官网下载实验demo进行实验。因为目前大多数厂商使用的都是OpenHarmony 1.0代码作为演示,不同的源码版本在编译规则和文件名上都会不同,所以在下载的源码中的头文件吊起等也要修改才能接入mqtt协议。
Mqtt最重要要吊起的功能文件在 /home/open/Downloads/code_v3.0LTS/OpenHarmony/third_party/pahomqtt/MQTTClient-C/src里,特别是liteOS中。
7.服务卡片
(1)服务卡片原理
(2)APPGallery Connect
①数字管家:
数字管家需要通过在APPGallery Connect中创建项目后添加应用从而获取Json文件,在完成下述的2后把此文件放在码云中下载的FA源码的:
DistSchedule\netconfig\src\main\resources中。然后按照文档开发UI界面,点击构建的Generate Key and CSR创建用户名与密钥进行签名。
官网在我的项目中创建项目,选择harmonyOS平台等完成填写
https://developer.huawei.com/consumer/cn/service/josp/agc/index.html#/
②逻辑处理:
(i)用户操作界面:在slice目录下新建 xxxSlice.java文件,通过addActionRoute方法为此AbilitySlice配置一条路由规则,并且在在应用配置文件(config.json)中注册。在resources/base/layout下新建对应xml布局文件,在上述两个文件中编写相应的UI。
(ii)数字管家数据处理:从slice获取deviceId:在onStart中通过调用DeviceID等,获取设备的名称等方便数字管家识别设备。从slice页面获取状态:开关锁可以直接调用intent.getBooleanParam来确定是进行开关锁还是对门锁的日程进行编排。
(iii)编写设备控制命令的解析:在CommandUtil中根据具体设备定义profile,来新增获取命令和解析命令的方法,用于设备在本地调用sendCommand来发送命令和解析。
(iv)配置设备端信息:在DeviceData的initData中,根据设备ProductID添加设备图片ID、跳转的action参数和解析方法,配置完成后设备列表页、用户页面等都能通过该配置进行图片加载、路由跳转和解析。
(v) NFC写入:最后进行接口对接与NFC写入就可以了(通过应用调试助手写入NFC识别详细用于快速让手机识别到设备从而吊起数字管家实现鸿蒙的Ability)可以写到开发板的NFC预存区,也可以写在huawei share的碰一碰卡片上。(目前这两种写法都可以写无数次,在下一次写入时会自动清除上一次所写的)
③开发方式:
(i) 用户操作界面:通过桌面可以在卡片中点击相关服务,卡片中可以呈现一个或多个服务。
(ii)工作原理:通过嵌入到UI界面拉起那款应用的服务(可以通过缓存实现快速打开)从而起到交互功能的原子化服务。
(iii)生命周期管理:对设备使用方的 RPC 对象进行管理,请求进行校验以及对更新后的进行回调处理。
(iv)卡片尺寸:目前官方有四种尺寸,可以在new中自己选中喜欢的尺寸。
(v)上手开发:新建一个服务卡片
选择自己所需的卡片框架
(vi)开发环节:创建完之后然后就可以看到在原有的subject中生成了config.json文件。js默认配置了卡片大小等信息,froms下的是ability中生命周期管理的核心部分(用于回调),会在主函数中实现调用。
要在这里把false改成true。
上图的文件包为主要的开发位置,开发者动的是index下的三个包。
完成签名之后在在线调试的实验机器上运行后就会产生一张纯的FA卡片了,此时环境已经搭建完毕。
本地缓存调取:src在main下的resources中建rawfile用于存放缓存,在编译时候打包进hap中怼到鸿蒙设备中即可get到。
下面以开发1*2的mini卡片为例,在本地预置了缓存文件后我们目光转向卡片,继续把播放按钮与卡片解耦开,通过hml塞入显示信息等。isWidget当true时,card_containerdiv就会变为div布局。Ispause为true时,按钮呈现播放;为false时,显示暂停按钮。
在 css 文件采用原子布局的display-index。display-index 的值越大,则越优先显示。在 main中的onCreateForm 里isMiniWidget 的data设置为 true。
在.json和main中相对应的地方添加点击事件,到此为止就可以通过点击卡片就可以得到start与stop的互动了。做完显示界面以后,接入界面与预置的本地缓存,然后封装即可。
上图上中下分别是更新(onUpdateForm),删除(onDeleteForm),事件消息(message),
更新(onUpdateForm): 卡片更新与持久化储存卡片,定时更新与请求更新时进行调用。
删除(onDeleteForm):用于删除卡片时调用。 图三:formid&massage,接收通知。一张Fa卡片创建时需要满足的基本功能:布局加载~请求数据(ohos&intent)~产生卡片(long&生成ID用于调用){通过枚举值得到}。
这样一张服务卡片就开发好了。
四、创新点描述
1. 关于智能门锁:
基于OpenHarmony开发,使用原子化服务,拥有密码解锁,NFC解锁,数字管家控制等功能。
2. 关于储物精灵
基于OpenHarmony开发,使用原子化服务,密码解锁,NFC解锁,防火帘控制,分布式软总线控制等。
3. 关于逆变器
基于OpenHarmony开发,拓扑架构大幅度缩小转换器桥臂和EMI滤波器的尺寸,在算法使用CEC加权效率设计与峰值电压追踪,通过品质因数公式FOM算出使用合适的GaN半导体选型结合五个桥臂的设计可以最小化逆变器的能量传递。
五、成果展现
1. 编译成功
2. 动图演示(导入到word中是动图,word可能无法显示出动图效果所以把相关图片动图在上传文件夹中备份了一份)
以下动图分别是门锁的舵机驱动,NFC打卡,智能门轨的演示动图。
我正在参加【有奖征文 第25期】深度体验OpenHarmony对接华为云IoT,输出优质体验文章,赢开发者定制大礼包!https://bbs.huaweicloud.com/blogs/406570
- 点赞
- 收藏
- 关注作者
评论(0)