家里小朋友养了一只小乌龟,到了冬天就冬眠了,早早地准备了一个冬眠箱,铺上椰土,在室温低于15℃时,就把小乌龟放到冬眠箱里,不一会儿它就自己钻入土中把自己藏了起来。按照惯例,需要每隔一定时间,对冬眠箱进行补水,以保持土壤湿润,防止小乌龟缺水,但有时候也会忘记补水的工作,造成冬眠箱过于干燥,不利于乌龟健康。
翻箱倒柜,找到一个9年前买的树莓派2 Model B,32位,4核1GB的设备,正好可以利用起来,做一个冬眠箱湿度实时监控系统,设计一下用户需求,大致如下:
- 每隔一定时间,采集冬眠箱中土壤的湿度数值,并将数据推送到网上的数据库中
- 提供一个前端页面,这个页面负责从数据库中读取数据,并以图表形式展现湿度走势
- 在这个前端页面上,通过人工智能AI服务,给出乌龟冬眠箱内的补水建议,比如建议几天后或者什么时机应该考虑补水等等
这个需求其实没有做到业务闭环:理论上讲,这个前端页面只不过是提供给我一个访问湿度数据并获得AI建议的一个"周边"功能而已,真正做的更为完整的话,应该是,在获得AI建议后,根据AI建议,将补水指令发送到设备,设备控制继电器完成自动补水,而不是让我看到数据后,再自己拿起喷水壶走向乌龟冬眠箱。
废话不多说,直接开整。
技术设计与实现效果
总结起来,我打算使用下面的这些硬件、技术和软件开发框架,来完成整个系统的实现:
- 硬件:树莓派2 Model B,负责从土壤湿度传感器读入数据,然后推送到Microsoft Azure IoT Hub
- 在数据被推送到IoT Hub前,使用ADS1115模数转换模块,将传感器模拟量转换为数字量,交由树莓派处理
- 树莓派2 Model B中,使用C语言编程,由Azure IoT C SDK实现与Microsoft Azure IoT Hub的交互;使用pigpio实现树莓派GPIO和I2C模数转换数据采集
- 树莓派中运行的这个数据采集程序,由cron服务负责调度,每15分钟运行一次程序,在运行时采集一次数据,推送一次数据
- 数据推送到IoT Hub后,通过Azure Function,将数据插入到后端的Azure Database for PostgreSQL flexible server数据库
- 使用ASP.NET Core Web App (Razor Pages)实现前端页面,访问PostgreSQL数据库,提供数据查询和呈现能力,数据趋势图表使用chartjs渲染
- 在这个前端页面上,通过Ajax异步调用,由Microsoft Semantic Kernel访问Azure OpenAI Services,通过预先部署好的gpt-4o模型,获取补水建议,并把结果显示在页面上
在这些技术的选择上,有些地方是经过一些考量并最终决定方案的:
- 选用C语言编程,而不是Python或者.NET,因为我对Python并不熟悉,加上树莓派2 Model B本身配置不高,所以跑.NET会比较耗费资源;因此,在现有的条件下,对于我来说,C语言是实现最快最方便的
- 使用Cron定时任务来调度程序,而不是让程序自己长期驻留后端,在程序内部每隔一段时间做一次数据采集和上传,原因如下:
- Cron功能简单易用,Cron表达式灵活度非常强,可以随时调整调度时机
- 程序长期驻留后端,更容易出现问题,比如如果编程习惯不好,产生内存泄漏,时间一长势必把系统搞挂,不利于系统稳定运行
- 反复的GPIO I2C调用,容易产生缓存和脏数据,造成数据错误,每次调度都重新启动一次进程,可以避免这类问题的发生
- 选择Microsoft Azure作为服务层的基础设施,因为它能提供全套所需后端服务,生态也比较成熟,而且我每个月还是有那么一点点额度在上面
- 选择ASP.NET Core Web App (Razor Pages)作为前端技术,而不是选择所谓的单页面应用和前后端分离的架构,是因为虽然我每个月有那么一点点Azure额度,但是不多,省一个算一个,不想因为一个简单的应用把东西搞得太复杂
从整体上看,整个系统的架构如下图所示:

在整个系统完成之后,通过使用手机访问部署于Azure App Service的前端页面,我们可以看到如下的效果:
在这个页面的上半部分,提供时间区间选择功能,可以指定数据观察的起始时间和结束时间,点击【确定】按钮后,在页面的下半部分就会用曲线图来显示这个时间区间中的数据。其中"历史数据"部分显示了各个时间点(每15分钟)的湿度数据,而"湿度趋势"则是将每6个小时的数据进行平均,然后显示在曲线图上。需要注意的是,每个数据点的值并不是对应真实的物理上的"湿度"概念,它只是一个参考值,在通过I2C采集数据时,我并没有对数据进行特殊处理,所以,这个值越大,表明传感器两侧之间的电阻值越大,也就是模拟量输出端(AO)上的电压越高,这也就意味着土壤湿度越小,越干燥,根据多次实验,确定了如果土壤干燥程度很严重,这个相对值是31840(电压就是31840 * 2.048 / 32768 = 1.99V)。所以可以从上面的图表看到,随着时间的推移,土壤变得越来越干。
在这个页面的中间部分,提供了"听听AI怎么说"功能,它通过将近期的数据汇总并发送给gpt-4o大语言模型,并由gpt-4o给出建议,显示在页面上,一开始的时候,这个建议不是特别靠谱,随着时间的推移,能够给出的参考数据越来越丰富,它的推测也越来越显得合理了。
源代码
所有代码都放在了码云上了,方便国内读者访问:https://gitee.com/daxnet/humidty。代码都在src
目录下:
function
子目录:保存了用于将Azure IoT Hub中的湿度数据保存到后端PostgreSQL数据库的Azure Function App的项目源代码iot
子目录:保存了从树莓派的土壤湿度传感器读入数据,并将数据推送到Azure IoT Hub的C语言程序web
子目录:保存了前端页面以及通过Semantic Kernel调用Azure OpenAI Services获取AI建议的ASP.NET Core Web Pages项目代码
技术实现
技术实现分为硬件连接与调试、Azure云环境搭建以及软件开发部分。当然这里也无法单靠一篇文章就把所有的细节都解释清楚,我会挑一些重点内容进行介绍。
硬件连接与调试
主机就是闲置的树莓派2 Model B,上网搜索了一下,这个型号的低配树莓派价格好像还很坚挺,也要小两百块,如果不是手上有个闲置,大概率我会入手一个基础版的树莓派Zero或者是Arduino开发板,价格会相对亲民。此外,土壤传感器是必不可少的,某宝上一大把,随便入手一个就行,价格也很便宜,就几块钱的事情:

当然,模数转换模块(ADC)也必不可少,因为传感器的AO端口它会输出模拟量(连续量),而树莓派GPIO本身读入的是数字量(0或者1),因此,中间需要做一个转换。可以考虑入手ADS1115的ADC模块,我是直接购买了适用于树莓派的ADS1115 ADC,这样直接往树莓派的GPIO上一插就完事儿了,省得自己还需要去排线,减少工作量。我购买的是下面这款,只是相对于基础版的ADS1115,这一款价格稍微有点高,大概3、40块的样子。

将ADS1115插在树莓派GPIO端口上,然后,按下面的图纸接线将湿度传感器接入即可:

当时购买ADS1115时,不知道是不是少发了跳线帽,手头也没有现成的,所以暂时只能用杜邦线直连0x48地址跳线(下图黄色的那根),加上也没有找到使用说明,所以当时也只能用万用表来测试是否连线正确:

调试通过后,将湿度传感器插入乌龟冬眠箱,效果如下。由于传感器顶部有裸露的接线线头,所以还特地用废弃的酒精瓶做了一个防水罩,以免补水的时候造成短路。

接下来就是配置Azure云服务和软件开发部分了。
配置Microsoft Azure云服务
在整个系统方案中,使用了下面这些Azure云服务:
- Azure IoT Hub(包含内建Event Hub)
- Azure Database for PostgreSQL flexible server
- Azure Function App
- Azure App Service
- Azure OpenAI Services
当然,还有一些基础服务,比如Virtual Network、DNS Zone、Private Endpoint等等,这些也就不一一列举了。事实上,配置过程内容也不少,这里也就不一步步介绍了,这里仅对其中主要的部分进行介绍。
Azure IoT Hub
直接从Azure Portal的主页上,选择IoT Hub服务新建就可以了,整个过程比较简单,在创建IoT Hub服务之后,记得添加一个IoT设备。由于我们的应用场景比较简单,所以,直接创建设备就行,在设备创建完之后,点击已创建的设备,然后在设备页面中,将Primary connection string复制保存下来,后面会用到:

此外,在IoT Hub的Hub settings 中,找到Event hub-compatible name 和Event hub-compatible endpoint,也复制保存下来,后续也会用到:

与IoT Hub配置相关的内容也就这些,其它选项默认即可。
Azure Database for PostgreSQL flexible server
在创建Azure Database for PostgreSQL flexible server资源之前,需要先把整个解决方案的网络拓扑设计好,否则到后面发现错误需要修改,就会变得很被动,比如如果一开始的时候网络配置不正确,就会影响后续的服务部署,或者你所使用的Subscription在有些区域有服务限制,从而造成某些资源无法创建的尴尬局面。
在创建Azure Database for PostgreSQL flexible server时,我选择了Development模式,因为这种模式最省钱,它本身也就只是为了开发测试的目的,而不是为生产环境而配置的,不过在我的场景中,已经够用了。另外为了安全起见,数据库默认是不会打开公网访问的,这也就意味着需要有对应的虚拟网络和子网的配置。在Azure中,PostgreSQL flexible server需要被部署在一个独立的子网中,这个子网至少需要有16个可用IP地址(CIDR范围:/28),在这16个地址中,Azure会使用其中的5个地址用于Azure网络相关的目的,剩下的11个地址中,如果PostgreSQL flexible server配置为高可用,它还将占用另外4个IP地址。
正如上文架构图中所述,我创建了一个Virtual Network,它包含两个子网:subnet-default
和subnet-pgsql
。Azure Database for PostgreSQL flexible server被部署在了subnet-pgsql
子网中。为了能让Azure Function App和Azure Web App能够访问数据库,在PostgreSQL数据库上,我还配置了Private Endpoint:

这个Private Endpoint是附着在subnet-pgsql
子网上的,并且由privatelink.postgres.database.azure.com
这个Private DNS负责域名解析。在Private Endpoint的DNS configuration中,将FQDN复制下来,这就是数据库中连接字符串的主机名称。

通过数据库的主页上的Connect链接,就能获得访问数据库的连接字符串,这里就不多做说明了。
Azure Function App
仍然在Azure Portal主页上,新建一个Azure Function App的资源,Azure Function App是需要由一个宿主(hosting)提供运行环境的,这个宿主环境可以有多个选择,在Azure中称为Hosting plan。Azure提供下面这些Hosting plan:

我选择的是App Service hosting plan,此时,它需要在你的Subscription下创建一个App Service Plan,一个App Service Plan其实是定义了一组计算资源(如虚拟机实例、CPU、内存、存储等)和功能级别,用于支持托管在该计划下的应用程序。通过选择不同的 App Service Plan,就可以根据应用程序的需求来调整计算资源和功能级别,以满足性能、可用性和成本方面的要求。因此,请量力而行,我所选择的App Service Plan如下,仅供参考,不好意思,囊中羞涩,选了个最便宜的方案:

在创建完Azure Function App之后,别忘了启用VNet Integration,否则你的Function App无法访问PostgreSQL数据库。启用过程也比较简单,首先在Azure Database for PostgreSQL flexible server的子网所在的虚拟网络中,另外再新建一个子网,然后,将Function App的子网设置为这个新建的子网就可以了。
此外,由于我们的Azure Function App需要从IoT Hub读取IoT事件,并将事件数据写入数据库,因此,需要配置如下这些环境变量:
ConnectionStringSetting
:设置为IoT Hub上内建(Built-in Endpoint)的Event hub-compatible endpoint地址(上文中有提到)PostgresConnectionString
:数据库的连接字符串(使用Private Endpoint的地址)
说明一下,这个"ConnectionStringSetting"的取名是任意的,你也可以选择不取这个名字,但是,它需要跟将来Azure Function App代码中的配置保持一致。
Azure App Service
与创建Azure Function App类似,直接从Azure Portal上新建App Service资源就行,在创建Azure App Service时,同样需要选择一个App Service plan,可以考虑使用上面Azure Function App相同的Service Plan,当然,如果经济条件允许,并且有另外的需求的话,则可以选择使用另一个独立的Service Plan,以使用不同的系统配置和计价模式。此外,由于我们的前端应用仍然需要访问PostgreSQL数据库,因此,与Azure Function App类似,需要启用VNet Integration,方法类似,不再赘述。
Azure App Service前端应用需要使用以下这些环境变量,这里大致介绍一下:
AzureOpenAIApiKey
:在完成Azure OpenAI Services大语言模型的部署之后,可以获得大语言模型的访问密钥,将密钥内容填入此处AzureOpenAIEndpoint
:在完成Azure OpenAI Services大语言模型的部署之后,可以获得大语言模型的访问目标URI,从而获得Endpoint地址,填入此处AzureOpenAIModelId
:在部署Azure OpenAI Services大语言模型时所选取的模型名称DbConnectionString
:PostgreSQL数据库连接字符串,与上述Azure Function App的PostgresConnectionString
环境变量取值相同
Azure OpenAI Services
在我之前的文章《在C#中基于Semantic Kernel的检索增强生成(RAG)实践》中,包含了如何在Azure上部署大语言模型的相关介绍,因此,这里就不再重复了。事实上,这套乌龟缸湿度监控系统中所使用的大语言模型,正是当时写那篇文章时所使用的大语言模型,因此,这里所使用的OpenAI API Key、Open AI Endpoint以及Model ID这些参数,都跟当时所使用的参数是相同的。
完成微软Azure云服务的配置之后,就可以开始进行编码开发了。
软件编码与实现
软件部分包括树莓派中收集湿度数据并推送到Azure IoT Hub的一个小程序,一个将Azure IoT Hub上的数据保存到后端PostgreSQL数据库的Azure Function App,以及一个用来显示湿度数据趋势和AI推荐的前端页面。
树莓派中应用程序的开发
在树莓派中,需要有一个应用程序专门负责收集湿度数据,然后将数据推送到Azure IoT Hub。我选择使用pigpio库来访问土壤湿度传感器,以获得湿度模拟量数据,并使用Azure IoT C SDK实现数据上传到Azure IoT Hub,编程使用C语言。首先是在树莓派中安装pigpio库,按照【官方文档】中介绍的步骤安装就可以了,安装过程基本就是下载源代码然后在本地编译安装。然后就是安装Azure IoT C SDK,并配置Visual Studio Code开发环境,我已经把详细步骤整理在代码库的文档中了,详情可以直接点击【这篇文档】获取,这里就不详细展开介绍了,重点介绍一下开发的几个要点。
第一件事情是从湿度传感器读取湿度数值,它是通过I2C(Inter-Integrated Circuit)实现的,所以需要在树莓派上启用I2C的支持,在树莓派命令提示符下,输入sudo raspi-config
,打开设置界面,然后选择Interface Options:

在子菜单中,选择I2C然后启用就可以了:

下面是通过I2C访问湿度传感器获取湿度数据的主要代码:
#include <pigpio.h>
#define I2C_ADDR 0x48 // 上面跳线所选择的地址
#define I2C_CONFIG_HI 0xC4 // I2C的配置高位字节
#define I2C_CONFIG_LO 0x83 // I2C的配置低位字节
#define I2C_CONFIG_REG 0x01 // 配置数据写入寄存器
static float get_humidty_value ()
{
// 初始化pigpio库
if ( gpioInitialise() < 0 )
{
log_error ( "GPIO initialize failed." );
return -1;
}
// 打开I2C
int i2c_handle = i2cOpen ( 1, I2C_ADDR, 0 );
if ( i2c_handle < 0 )
{
log_error ( "I2C open failed, error no: %d", i2c_handle );
return -1;
}
// 写入配置数据,对I2C进行配置
char config[2] = { I2C_CONFIG_HI, I2C_CONFIG_LO };
int config_res = i2cWriteI2CBlockData(i2c_handle, I2C_CONFIG_REG, config, 2);
if ( config_res != 0 )
{
switch ( config_res )
{
case PI_I2C_WRITE_FAILED:
log_error ( "I2C write failed." );
break;
case PI_BAD_HANDLE:
log_error ( "I2C write bad handle.");
break;
case PI_BAD_PARAM:
log_error ( "I2C write bad parameter." );
break;
}
return -1;
}
time_sleep ( 0.2 );
// 从I2C读入数据并保存在一个字节数组中
char data[2];
int num_bytes_read = i2cReadI2CBlockData ( i2c_handle, 0x00, data, 2 );
if ( num_bytes_read <= 0 )
{
switch ( num_bytes_read )
{
case PI_I2C_READ_FAILED:
log_error ( "I2C read failed." );
break;
case PI_BAD_HANDLE:
log_error ( "I2C read bad handle.");
break;
case PI_BAD_PARAM:
log_error ( "I2C read bad parameter." );
break;
}
return -1;
}
// 通过字节数组数据的拼装,得到湿度数据
float result = (data[0] << 8) | data[1];
// 计算出电压值,仅作日志输出参考使用
float voltage = result * 2.048 / 32768.0;
log_info ( "ADC value: %.3f, Voltage: %.3f", result, voltage );
i2cClose ( i2c_handle );
gpioTerminate ( );
// 将结果返回
return result;
}
在获得湿度数据之后,就可以通过Azure IoT C SDK,将数据推送到Azure IoT Hub上。主体代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <azureiot/iothub.h>
#include <azureiot/iothub_client_version.h>
#include <azureiot/iothub_device_client_ll.h>
#include <azureiot/iothubtransportmqtt.h>
#include <azure_c_shared_utility/threadapi.h>
#define CONNECTION_STRING_NAME "IOTHUB_CONNECTION_STRING"
// 发送出去的消息数目
static int g_message_count_send_confirmations = 0;
// 消息发送之后的确认回调
static void send_confirm_callback ( IOTHUB_CLIENT_CONFIRMATION_RESULT result, void* userContextCallback )
{
g_message_count_send_confirmations++;
log_info ( "Confirmation callback received for message %lu with status %s",
( unsigned long )g_message_count_send_confirmations,
MU_ENUM_TO_STRING(IOTHUB_CLIENT_CONFIRMATION_RESULT, result)
);
}
// 与Azure IoT Hub建立连接后的回调
static void connection_status_callback(IOTHUB_CLIENT_CONNECTION_STATUS result,
IOTHUB_CLIENT_CONNECTION_STATUS_REASON reason,
void* user_context)
{
if ( result == IOTHUB_CLIENT_CONNECTION_AUTHENTICATED )
log_info ( "The device client is connected to iothub." );
else
log_info ( "The device client has been disconnected." );
}
// 发送数据
static void send_message ( IOTHUB_DEVICE_CLIENT_LL_HANDLE handle, RPI_MESSAGE_HANDLE message_handle )
{
const char* serialized_message = Rpi_SerializeMessage ( message_handle );
log_debug ( "Message: %s", serialized_message );
IOTHUB_MESSAGE_HANDLE iot_message_handle = IoTHubMessage_CreateFromString( serialized_message );
if ( iot_message_handle == NULL )
{
log_error ( "Failed to create message handle from String." );
return;
}
IOTHUB_MESSAGE_RESULT send_res = IoTHubClientCore_LL_SendEventAsync ( handle, iot_message_handle, send_confirm_callback, NULL );
if ( send_res != IOTHUB_MESSAGE_OK )
{
log_error ( "IoTHubClientCore_LL_SendEventAsync call failed with status %s", MU_ENUM_TO_STRING ( IOTHUB_CLIENT_RESULT, send_res ) );
}
else
{
do
{
IoTHubDeviceClient_LL_DoWork ( handle );
ThreadAPI_Sleep ( 1 );
} while (g_message_count_send_confirmations < 1);
g_message_count_send_confirmations = 0;
}
IoTHubMessage_Destroy ( iot_message_handle );
}
int main ( int argc, char** argv )
{
// 从环境变量或者命令行获得Azure IoT Hub的连接字符串
const char* iothub_connection_string = getenv ( CONNECTION_STRING_NAME );
if ( iothub_connection_string == NULL )
{
if ( argc == 2 )
{
iothub_connection_string = argv[1];
}
if ( iothub_connection_string == NULL )
{
log_error ( "Error: Missing IOTHUB_CONNECTION_STRING environment variable. Terminated." );
fclose ( fp_log );
return -1;
}
}
// 初始化IoT C SDK库
int init_res = IoTHub_Init();
if ( init_res != 0 )
{
log_error ( "IoT Hub initialize failed." );
fclose ( fp_log );
return -1;
}
log_info ( "IoT Hub client version: %s", IoTHubClient_GetVersionString() );
// 创建设备连接
IOTHUB_DEVICE_CLIENT_LL_HANDLE device_handle =
IoTHubDeviceClient_LL_CreateFromConnectionString ( iothub_connection_string, MQTT_Protocol );
if ( device_handle == NULL )
{
log_error ( "Can't get device client handle. Check connection string." );
fclose ( fp_log );
return -1;
}
IoTHubDeviceClient_LL_SetConnectionStatusCallback ( device_handle, connection_status_callback, NULL );
// 获取湿度值
float humidty_val = get_humidty_value ( );
if ( humidty_val > 0 )
{
// 基于湿度值构建一条待发送的消息
RPI_MESSAGE_HANDLE message = Rpi_CreateMessage ( humidty_val );
// 将消息发送到IoT Hub
send_message ( device_handle, message );
// 释放消息所占用的资源
Rpi_DestroyMessage ( message );
}
else
{
log_error ( "Message was not sent due to a failure in getting humidty value." );
}
// 关闭IoT Hub连接并释放资源
IoTHubDeviceClient_LL_Destroy ( device_handle );
// 释放IoT Hub C SDK资源
IoTHub_Deinit();
fclose ( fp_log );
return 0;
}
限于文章篇幅,没有将所有代码贴出,感兴趣的话可以访问https://gitee.com/daxnet/humidty/tree/master/src/iot来阅读这部分的代码。从这部分代码可以看到,每次运行这个程序,它会收集一次数据,然后调用一次Azure IoT Hub将数据推送出去。为了达到每隔一定时间进行一次数据采集和推送的目的,我使用了Linux下的cron服务。由于调用pigpio的API需要使用root权限,因此,cron服务的配置也需要基于root用户,于是就应该使用下面的命令来编辑crontab文件:
sudo crontab -e
然后在文件中加入下面这行即可,表示每15分钟执行一次:
*/15 * * * * /home/daxnet/projects/humidty/src/iot/main "<iot_hub_connection_string>"
cron表达式的后面跟着的就是上面的C语言代码编译出来的可执行程序,编译命令类似如下。我并没有使用cmake等编译工具集来执行编译任务,因为我们的程序比较简单,没有必要搞得太重:
gcc -Wall -fdiagnostics-color=always -g main.c rpi_message.c log.c -o main \
-liothub_client -liothub_client_mqtt_transport -lumqtt -lprov_device_client \
-lprov_auth_client -lhsm_security_client -lutpm -laziotsharedutil -lpthread \
-lcurl -lssl -lcrypto -lm -lparson -lprov_mqtt_transport -lpigpio -lrt
Azure Function App的开发
在整个解决方案中,Azure Function App的作用是将推送到Azure IoT Hub的消息内容保存到后端的Azure Database for PostgreSQL flexible server数据库中,以便接下来的前端页面可以使用。可以直接使用Visual Studio 2022来实现Azure Function App的开发,开发需要安装Azure开发工作负载:

然后在新建项目时,选择Azure Functions项目模板:

Azure Function App的业务代码非常简单,如下:
public class HumidtyStoringFunction(ILogger<HumidtyStoringFunction> logger)
{
private const string DatabaseTableName = "public.humidty_history";
[Function("humidty_storing_function")]
public void Run([EventHubTrigger(
"iothub-ehub-humidty-io-56972526-7565e081a6",
Connection = "ConnectionStringSetting")] EventData[] events)
{
var dbConnectionString = Environment.GetEnvironmentVariable("PostgresConnectionString");
if (string.IsNullOrWhiteSpace(dbConnectionString))
{
logger.LogError("Database connection string is not specified, function will not proceed.");
return;
}
try
{
// 初始化PostgreSQL连接
using var sqlConnection = new NpgsqlConnection(dbConnectionString);
// 对于收到的每一个事件(消息)
foreach (var @event in events)
{
var eventJson = Encoding.UTF8.GetString(@event.EventBody);
logger.LogInformation($"Processing event {eventJson}");
var jobject = JObject.Parse(eventJson);
// 获得时间和湿度值
var t = jobject.GetValue("t")?.Value<long>();
var v = jobject.GetValue("v")?.Value<double>();
if (t is null || v is null)
{
logger.LogError("Event received but the content is incorrect, function will not proceed.");
return;
}
// 将时间和湿度值插入数据库
var sql = $@"INSERT INTO {DatabaseTableName} (""time"", ""value"") VALUES ({t}, {v})";
sqlConnection.Execute(sql);
logger.LogInformation("Event processed successfully.");
}
}
catch (Exception e)
{
// 如果出错,则写日志,并抛出
logger.LogError(e, "Failed to process event, exception details as below...");
throw;
}
}
}
有两点需要注意:
EventHubTriggerAttribute
中的Connection
参数指定的是保存Azure IoT Hub连接字符串的环境变量的名称(这里是ConnectionStringSetting
),而不是连接字符串本身- 使用try...catch合理地处理异常,此处建议在代码中完成异常处理之后,使用throw语句将异常抛出,这样,在Azure Function App的管理页面中,就会产生一个执行失败的记录
整个Function App的完整项目代码可以参考代码库:https://gitee.com/daxnet/humidty/tree/master/src/function。
Azure App Service前端应用的开发
前端应用的开发使用的是ASP.NET Core Web App (Razor Pages)项目模板,集成Chart.js实现曲线图的显示。详细代码这里就不贴出来了,可以直接访问代码库来查看完整的项目代码:https://gitee.com/daxnet/humidty/tree/master/src/web。只是在获取AI建议的时候,调用会比较慢,为了不影响页面加载和用户体验,我简单粗暴地使用Ajax实现AI建议的获取,并异步地将结果显示在界面上。在Index.cshtml代码文件中,页面加载完成时调用Ajax:
$(document).ready(function(){
$.ajax({
type: "GET",
url: "/?handler=AISuggestion",
contentType: "application/json",
dataType: "json",
success: function (result) {
$('#aiSuggestion').html(result);
}
});
});
然后,在Index.cshtml.cs后台文件中,实现这个AISuggestion方法:
public async Task<JsonResult> OnGetAISuggestionAsync()
{
var startTime = DateTime.Now.AddDays(-7);
var endTime = DateTime.Now;
var recentHumidtyData = await Utils.GetHumidtyHistory(startTime, endTime, _connectionString);
var avgHumidtyData = recentHumidtyData
.GroupBy(h => h.Time / 3600 / 6)
.Select(g => new HumidtyHistory
{
Time = g.First().Time,
Value = g.Average(h => h.Value)
})
.ToDictionary(h => Utils.UnixTimestampToLocalDateTime(h.Time), h => h.Value);
var sb = new StringBuilder();
sb.AppendLine("下面最近7天内每6小时的平均数据趋势:");
var sbResponse = new StringBuilder();
foreach (var kvp in avgHumidtyData)
{
sb.AppendLine($"时间:{kvp.Key.ToShortDateString()} {kvp.Key.ToShortTimeString()},数据:{kvp.Value}");
}
sb.AppendLine($"""
数据越接近31840,表明冬眠箱越干燥,越需要补水,数据越接近0,表明冬眠箱越湿润,不需要补水。
现在时间是{DateTime.Now.ToShortDateString()} {DateTime.Now.ToShortTimeString()},请预测乌龟冬眠箱补水的大致时间。
""");
_chat.Clear();
_chat.AddUserMessage(sb.ToString());
await foreach (var message in _chatCompletionService.GetStreamingChatMessageContentsAsync(_chat))
{
sbResponse.AppendLine(message.Content);
}
return new JsonResult(sbResponse.ToString());
}
这段代码会将最近7天内的数据,每6小时做一个平均,然后作为上下文文本提供给AI,然后让AI基于现在的时间来预测乌龟冬眠箱需要补水的大致时间。
总结
通过软硬结合,借助云计算平台和AI来实现一个解决实际问题的方案,确实是一件有趣的事情。内容比较多,本文也只是在整个乌龟冬眠箱适度监控和AI建议解决方案的各个部分进行一些简单粗略的介绍,但应该已经基本涵盖了主体流程的各个部分。如果对于某些细节问题希望能够深入展开,欢迎留言讨论。