| 注:本文曾发表在博客园我的个人博客中,转载至此公众号以归档保存。
家里小朋友养了一只小乌龟,到了冬天就冬眠了,早早地准备了一个冬眠箱,铺上椰土,在室温低于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调用,容易产生缓存和脏数据,造成数据错误,每次调度都重新启动一次进程,可以避免这类问题的发生
从整体上看,整个系统的架构如下图所示:
在整个系统完成之后,通过使用手机访问部署于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开发板,价格会相对亲民。此外,土壤传感器是必不可少的,某宝上一大把,随便入手一个就行,价格也很便宜,就几块钱的事情:
| 配置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配置相关的内容也就这些,其它选项默认即可。
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:
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:
在创建完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:
static float get_humidty_value (){// 初始化pigpio库if ( gpioInitialise() < 0 ){log_error ( "GPIO initialize failed." );return -1;}// 打开I2Cint 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;}
// 发送出去的消息数目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." );elselog_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 == ){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, );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 == ){if ( argc == 2 ){iothub_connection_string = argv[1];}if ( iothub_connection_string == ){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 == ){log_error ( "Can't get device client handle. Check connection string." );fclose ( fp_log );return -1;}IoTHubDeviceClient_LL_SetConnectionStatusCallback ( device_handle, connection_status_callback, );// 获取湿度值float humidty_val = get_humidty_value ( );if ( humidty_val > 0 ){// 基于湿度值构建一条待发送的消息RPI_MESSAGE_HANDLE message = Rpi_CreateMessage ( humidty_val );// 将消息发送到IoT Hubsend_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;}
sudo crontab -e*/15 * * * * /home/daxnet/projects/humidty/src/iot/main "<iot_hub_connection_string>"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开发工作负载:
public class HumidtyStoringFunction(ILogger<HumidtyStoringFunction> logger){private const string DatabaseTableName = "public.humidty_history";[]public void Run([EventHubTrigger("iothub-ehub-humidty-io-56972526-7565e081a6",Connection = "ConnectionStringSetting")] EventData[] events){var dbConnectionString = Environment.GetEnvironmentVariable("PostgresConnectionString");if (string.IsOrWhiteSpace(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 || v is ){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);}});});
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建议解决方案的各个部分进行一些简单粗略的介绍,但应该已经基本涵盖了主体流程的各个部分。如果对于某些细节问题希望能够深入展开,欢迎留言讨论。