将Ubuntu的系统信息通过MQTT报告到Home Assistant
使用Home Assistant自然会想要把更多的设备集成到里面去。虽然它自带了很多集成,但对于通用的计算设备,想要集成进去依然依赖于设备本身能够提供信息。
开始的时候我添加了一些树莓派的设备节点,希望能够监控它们的状态。在网络上找到RPi-Reporter-MQTT2HA-Daemon这个项目,它可以将树莓派的状态通过MQTT报告到Home Assistant,感觉非常方便,就把它装进了这些设备里。但后来有了新的需求,而这个项目难以满足,因而受其启发另外开发了ubuntu-mqtt2ha-reporter来将安装Ubuntu的设备的状态报告到Home Assistant里。
动机
如前所述,RPi-Reporter-MQTT2HA-Daemon本身非常方便,装进设备然后运行,Home Assistant上自动就会出现这个设备及其相关的传感器。为什么我还要另外开发一个项目呢?原因是多方面的。
最大的原因当然是因为这个项目局限于树莓派。虽然在一开始我买了几个树莓派作为设备节点部署,但我越来越多地发现,它们既不便宜性能也不好。市面上有很多企业淘汰的二手SFF的x86设备,有时价格都不到A$100,但状态很好,不但自带了外壳、电源和更稳定的存储,还有着更快的CPU和更大的内存。除了体积稍微大一点,也许耗电量也稍微更高一些以外,差不多的价位下性能比树莓派高了一个档次。1收了一些这样的设备之后,我就开始需要能运行在非树莓派设备上的报告服务了。
我最开始的想法是fork那个项目,修改代码以使其不那么特定于树莓派。但看它的代码,我发现几个问题:
- 它有非常多地方是基于运行在树莓派的假设之上的。除了获取树莓派的设备信息之外,很多其他的数据也依赖树莓派系统的特定结构,比如文件系统信息、网络信息等。
- 它把所有的数据全部放在
Updated
传感器的属性中,这种做法是Home Assistant官方所不推荐的。 - 我不喜欢它的代码结构。因为使用了Python又想方便部署,因而整个代码全部集中在一个文件里。接近两千行的代码在一起,非常难以翻阅。
- 此外,这些代码不要说测试,甚至没有类型信息,不够熟悉的情况下很难保证修改了不会出问题。
因此我决定从头开始用Rust构建一个符合我自己需求的服务。它不需要有那个项目全部的功能,但至少它能用在x86设备上,依然方便部署,并且有更清晰和可扩展的代码结构方便添加更多的功能。
设计和实现
整个服务是跑在Tokio异步框架之上,使用rumqtt处理MQTT连接,配置文件使用TOML格式2,简单地使用了Serde来定义和解析。
服务的主循环包含了四个独立运行的循环:MQTT客户端的事件循环、定期发布可用性、接收命令处理和定期发布传感器状态。
传感器
每一类传感器由一个独立的模块实现,每个模块可以提供任意数量的传感器,比如CPU模块既提供了总的CPU使用率,也提供了每个逻辑CPU的使用率,分在不同的传感器中。但每个模块只能将数据报告到一个独立的MQTT话题上,通过值模板来为不同的传感器提取数据。全部的传感器模块都要实现trait Sensor
:
trait Sensor: 'static {
type Payload: Serialize + 'static;
fn topic(&self) -> &str;
fn discovery_data(&self) -> Vec<SensorDiscovery<'_>>;
async fn get_status(&self) -> Result<Self::Payload, Error>;
}
其中discovery_data
会在服务开始运行时被调用,以生成发现信息让Home Assistant能设置和接收相关的传感器状态信息。get_status
会被定期地调用以获得传感器数据来发送。发现数据和状态负载需要协同来保证传感器信息能正确地显示,比如CPU传感器发送的数据形如
{
"total": 0.4,
"per_cpu": [0.3, 0.5, 0.5, 0.4]
}
而它们的发现数据中则会指定
// CPU使用率
"value_template": "{{ value_json.total }}",
// CPU0使用率
"value_template": "{{ value_json.per_cpu[0] }}",
不同传感器模块提供的数据不需要同时发送,传感器循环会在对应的get_status
返回时发送。比如CPU传感器监控过去一分钟的CPU使用率,因而它在服务开始运行后一分钟才有能够报告的数据,而与之相反的,内存模块简单地读取/proc/meminfo
来获得数据,因而马上就能返回。这时内存状态就会先被发送,而CPU在有数据之后会被发送。
CPU传感器是特别麻烦的一个传感器。虽然在很多系统监控软件中它只是一个简单的百分比,但系统并没有直接提供这么一个百分比,这个百分比数字是需要自己计算的。这里的实现是,在一个独立的循环里每分钟读取一次/proc/stat
获得系统在不同任务上花费的总周期数,并根据这些数据计算在过去这一分钟里忙碌的周期占总周期的比例,将结果发送到一个通道。每次get_status
的时候,从这个通道读取最后一次更新的数据并将其返回。这里取一分钟的平均是一个折衷。大多数本地的监控软件可能会将采样间隔降到非常低,这样看过去CPU使用率的变动是连续的。但对于报告到远端,而且默认每5分钟才报告一次,100毫秒乃至一秒的采样间隔得到的数据感觉意义就变得小了很多,因而选择了使用一段更长的时间范围内的平均。
与CPU传感器类似的还有网络吞吐量的传感器,也是使用独立的循环去对总传输量进行采样,再除以采样间隔以获得吞吐的速率。
其他的传感器则大多比较简单,基本上只是解析系统提供的数据,像内存传感器就是读取/proc/meminfo
,负载传感器读取/proc/loadavg
,硬盘传感器使用statvfs
调用,APT传感器运行apt-get --just-print
,重启传感器读取/var/run/reboot-required
和/var/run/reboot-required.pkgs
等。
命令按钮
与传感器类似,每个命令按钮也是一个独立的模块,它们分别实现trait Command
:
trait Command: 'static {
fn topic(&self) -> &str;
fn discovery_data(&self) -> Vec<CommandDiscovery<'_>>;
async fn execute(&self) -> Result<(), Error>;
}
现有的命令按钮都只是简单地运行一条指令。Home Assistant也并不支持命令按钮返回任何信息,连是否成功也没有,因此内部接口和实现也都很简单。
但实现命令按钮的一个复杂性来自于特权的管理。作为安全性的最佳实践,这个服务本身应该跑在一个非特权用户上,但像重启、休眠这样的命令又需要root权限。解决的方法是配置sudoer
文件让这些特定的命令可以被服务所在的用户无密码执行:
ubuntu-mqtt2ha-reporter ALL=(root) NOPASSWD: \
/usr/bin/systemctl reboot, \
/usr/bin/systemctl suspend
发现数据
发现数据使用了Home Assistant基于设备的发现机制,因此所有的传感器和命令按钮都包行在一条发现数据的消息中。在我最初的实现中,包括RPi-Reporter-MQTT2HA-Daemon的实现,都为每一个项目发送一条独立的发现消息。这种方式每一条发现数据里都有很多重复的部分,包括设备标识符、连接信息、可用性话题等等。基于设备的机制解决了这一问题。
设备唯一标识符使用的是/etc/machine-id
的文档里推荐的方式:读取这个文件里存储的标识符,加上应用特定的密钥,然后进行密码学哈希,将得到的结果作为标识符使用。虽然systemd提供了sd_id128_get_machine_app_specific
函数,但出于我也不记得了的原因,我照着文档里描述的算法自己用HMAC-SHA256实现了一个。
生命周期管理
使用rumqtt来管理MQTT连接虽然并不复杂,但有一些比较微妙的技巧。这个包依赖使用者自己建立事件循环以推动消息的发送和接收,这意味着这个事件循环需要在自己独立的协程里运行,不能与发送、接收消息的代码共享一个协程,而这个协程需要在发送消息和订阅话题之前先开始执行,也要在所有消息都发送完成之后才能结束。
前面提到过,主循环中的一个循环要定期发送可用性情况,这个循环也负责在服务即将关闭的时候发送消息将设备标记为不可用。这也就是说,我们必须保证MQTT的事件循环要在这个标记不可用的消息发送之后才能结束。可以说,主循环中的四个循环并不是平等的:
- 发布传感器状态和接收命令的循环的生命周期并不重要,它们在服务即将关闭时,有没有收到、有卖有发出我们并不在意,所以任何其他循环退出,它们也会跟着结束。
- 而可用性循环不同,我们要保证它有机会发出最后的消息,因此设计了由它在发送这条消息之后去断开客户端连接。
- 事件循环则会在处理到断开连接的请求时退出,而主循环也会在其他三个循环退出之后,额外等待事件循环结束,以保证进程不会在消息全部发出之前就退出。
请求主循环结束的方式有两种:
- 收到
SIGINT
或SIGTERM
信号,系统请求停止进程。在调试或systemd停止服务时是常见的结束主循环的方式,也会让整个进程退出。 - 检测到系统即将进入休眠。这个服务运行的过程中会持有一个systemd的抑制锁,一旦检测到系统即将进入休眠,主循环也会被结束以保证设备在进入休眠之前被正确地标记为不可用,接着在主循环结束之后,服务会释放抑制锁并等待系统再次被唤醒。3
Debian包
开始的时候,我部署这个服务只是简单地把文件传到目标设备然后手动进行配置。但随着部署的设备变多和一段时间内版本频繁更新,手动部署显得非常繁琐。虽然也想过用一些自动化配置的方案,但最后考虑到我的目标只是Ubuntu设备,用自带的有着超级牛力的APT包管理不是最方便的吗?
搜索发现cargo-deb这个工具支持直接把Rust项目打包成deb文件,于是就用了。用deb包就可以自动化地创建需要的用户和组、配置systemd服务、配置sudoer文件等。之后为了能更方便地部署,还建立了一个私人的APT源,并在我的目标机器里配置了让它能自动更新。4当然配置私人APT源又是另外的话题了。
用例
这个服务最大的用例当然是监控各种设备,我把它部署在了我的VPS和家里运行Ubuntu的设备上以监控它们的状态。因为有传感器显示设备是否需要重启以应用更新,也有命令按钮可以直接重启设备,我就可以直接通过Home Assistant来重启等待重启的设备,而不需要用SSH先连接上去。虽然这个过程也可以完全靠自动化实现,但我还是想保留一点手动的部分。
除此之外,我也用它提供的信息进行其他的自动化。比如说我在家里的电视上连了一台电脑当简陋的电视盒,播放一些因为某些原因无法直接在电视上播放的内容。我之前买了任天堂Switch以后发现,如果Switch连着电视,我打开Switch时电视会自动打开并切换到它对应的输入源,而我关闭电视时Switch也会自动关闭。查阅资料以后我相信这是通过HDMI-CEC机制控制的,然而虽然我的连接线支持HDMI-CEC,连接的那台电脑的显卡却不支持。但有了这个服务,我可以设置Home Assistant的自动化,让它检测到这台电脑被唤醒时自动打开电视并切换输入源,再设置另外一个自动化让它检测到电视被关掉时也自动让电脑进入休眠。
未来
我自己的需求基本上已经满足了,所以这个项目或许也会暂时再次转入不活跃的状态。
几个可能的未来改进包括:
- 将支持延伸到树莓派系统。树莓派系统也是基于Debian的,将现有的功能延伸到树莓派应该不会太困难,而且也可以帮助我将RPi-Reporter-MQTT2HA-Daemon从现在的树莓派节点上替换掉。但这也需要我能够构建aarch64的安装包。
- 这也许不是很困难的事,但我对现有的树莓派报告器并没有太大的不满,而且没有特别的需要我也懒得去折腾现在稳定运行的那些树莓派节点。
- 增加更多报告的项目,比如设备的制造商和型号、CPU温度等数据。
- 这些东西如何可靠地获得,如何在本地设备和VPS上有意义,都需要一些研究。
- 建立一个可以被公共使用的APT源方便其他人安装部署。
- 不是很确定这会有多大的需求,也不确定最好的方式是什么。一种可能的方式是使用Launchpad,另一种方式是用自己的VPS或额外的对象存储来当APT源。
无论如何,我觉得这个项目现在在一个还算可用的状态,如果有任何建议或改进,欢迎提issue或pull request。
脚注
-
我目前最喜欢的二手SFF是Dell Wyse 5070系列瘦客户端,耗电量与树莓派接近,性能却相当好。实际上最开始我的Home Assistant运行在一台Raspberry Pi 4B上,后来也被我迁移到了一台5070。迁移之后负载和核心温度大幅降低,更新插件一类的任务速度则显著提升。 ↩
-
虽然就现有的配置项目来说INI格式可能也足够了,但TOML还是保留了更多的可能性,并且也有更完善的格式定义,并且在Rust里有更成熟的支持。 ↩
-
最开始的做法是写脚本放进
/usr/lib/systemd/system-sleep/
在系统休眠之前自动停止服务,并在唤醒之后恢复。当我希望把它放进deb包的时候去读文档,才发现这个方法被认为是hack,而更正确的做法是使用抑制锁,这才转而使用这种机制。 ↩ -
cargo-deb构建的包原本是不被自动更新支持的,我为此给它提交了PR让它生成的包更接近其他的deb包,以获得更广泛的兼容性。 ↩