乌龟冬眠箱湿度监控系统和AI辅助建议功能的实现

家里小朋友养了一只小乌龟,到了冬天就冬眠了,早早地准备了一个冬眠箱,铺上椰土,在室温低于15℃时,就把小乌龟放到冬眠箱里,不一会儿它就自己钻入土中把自己藏了起来。按照惯例,需要每隔一定时间,对冬眠箱进行补水,以保持土壤湿润,防止小乌龟缺水,但有时候也会忘记补水的工作,造成冬眠箱过于干燥,不利于乌龟健康。

翻箱倒柜,找到一个9年前买的树莓派2 Model B,32位,4核1GB的设备,正好可以利用起来,做一个冬眠箱湿度实时监控系统,设计一下用户需求,大致如下:

  1. 每隔一定时间,采集冬眠箱中土壤的湿度数值,并将数据推送到网上的数据库中
  2. 提供一个前端页面,这个页面负责从数据库中读取数据,并以图表形式展现湿度走势
  3. 在这个前端页面上,通过人工智能AI服务,给出乌龟冬眠箱内的补水建议,比如建议几天后或者什么时机应该考虑补水等等

这个需求其实没有做到业务闭环:理论上讲,这个前端页面只不过是提供给我一个访问湿度数据并获得AI建议的一个"周边"功能而已,真正做的更为完整的话,应该是,在获得AI建议后,根据AI建议,将补水指令发送到设备,设备控制继电器完成自动补水,而不是让我看到数据后,再自己拿起喷水壶走向乌龟冬眠箱。

废话不多说,直接开整。

技术设计与实现效果

总结起来,我打算使用下面的这些硬件、技术和软件开发框架,来完成整个系统的实现:

  1. 硬件:树莓派2 Model B,负责从土壤湿度传感器读入数据,然后推送到Microsoft Azure IoT Hub
  2. 在数据被推送到IoT Hub前,使用ADS1115模数转换模块,将传感器模拟量转换为数字量,交由树莓派处理
  3. 树莓派2 Model B中,使用C语言编程,由Azure IoT C SDK实现与Microsoft Azure IoT Hub的交互;使用pigpio实现树莓派GPIO和I2C模数转换数据采集
  4. 树莓派中运行的这个数据采集程序,由cron服务负责调度,每15分钟运行一次程序,在运行时采集一次数据,推送一次数据
  5. 数据推送到IoT Hub后,通过Azure Function,将数据插入到后端的Azure Database for PostgreSQL flexible server数据库
  6. 使用ASP.NET Core Web App (Razor Pages)实现前端页面,访问PostgreSQL数据库,提供数据查询和呈现能力,数据趋势图表使用chartjs渲染
  7. 在这个前端页面上,通过Ajax异步调用,由Microsoft Semantic Kernel访问Azure OpenAI Services,通过预先部署好的gpt-4o模型,获取补水建议,并把结果显示在页面上

在这些技术的选择上,有些地方是经过一些考量并最终决定方案的:

  1. 选用C语言编程,而不是Python或者.NET,因为我对Python并不熟悉,加上树莓派2 Model B本身配置不高,所以跑.NET会比较耗费资源;因此,在现有的条件下,对于我来说,C语言是实现最快最方便的
  2. 使用Cron定时任务来调度程序,而不是让程序自己长期驻留后端,在程序内部每隔一段时间做一次数据采集和上传,原因如下:
    • Cron功能简单易用,Cron表达式灵活度非常强,可以随时调整调度时机
    • 程序长期驻留后端,更容易出现问题,比如如果编程习惯不好,产生内存泄漏,时间一长势必把系统搞挂,不利于系统稳定运行
    • 反复的GPIO I2C调用,容易产生缓存和脏数据,造成数据错误,每次调度都重新启动一次进程,可以避免这类问题的发生
  3. 选择Microsoft Azure作为服务层的基础设施,因为它能提供全套所需后端服务,生态也比较成熟,而且我每个月还是有那么一点点额度在上面
  4. 选择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 nameEvent 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-defaultsubnet-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;
        }
    }
}

有两点需要注意:

  1. EventHubTriggerAttribute中的Connection参数指定的是保存Azure IoT Hub连接字符串的环境变量的名称(这里是ConnectionStringSetting),而不是连接字符串本身
  2. 使用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建议解决方案的各个部分进行一些简单粗略的介绍,但应该已经基本涵盖了主体流程的各个部分。如果对于某些细节问题希望能够深入展开,欢迎留言讨论。

相关推荐
Elastic 中国社区官方博客3 小时前
Elasticsearch 混合搜索 - Hybrid Search
大数据·人工智能·elasticsearch·搜索引擎·ai·语言模型·全文检索
一位卑微的码农3 小时前
深入解析Spring Cloud Config:构建高可用分布式配置中心
分布式·spring cloud·微服务·架构
9命怪猫4 小时前
DeepSeek底层揭秘——微调
人工智能·深度学习·神经网络·ai·大模型
d3soft4 小时前
deepseek清华大学第二版 如何获取 DeepSeek如何赋能职场应用 PDF文档 电子档(附下载)
ai·pdf·教程·deepseek·赋能职场
说是用户昵称已存在7 小时前
Pycharm+CodeGPT+Ollama+Deepseek
ide·python·ai·pycharm
Crazy Struggle14 小时前
.NET 使用 DeepSeek R1 开发智能 AI 客户端
人工智能·ai·.net·deepseek
程序员鱼皮14 小时前
2分钟学会 DeepSeek API,竟然比官方更好用!
计算机·ai·互联网·项目
Urf_read15 小时前
微服务入门-笔记
redis·笔记·微服务
dingdingfish19 小时前
Oracle LiveLabs实验:Oracle AI Vector Search - Basics
ai·oracle·vector·database·converged
d3soft20 小时前
【清华大学】DeepSeek从入门到精通完整版pdf下载
ai·pdf·教程·deepseek