☰
农业物联网设备离线排查:两份可直接跑的 Python 脚本
2026/10/27 2:06:23 网站建设 项目流程

棚区停止上传数据时,运维群里最常见的一句是「去看看那台设备」。这句话把判断和执行压成了一步:还没确定断在哪一段,人已经在路上。下面两份脚本不依赖第三方库,第一份把事件流算成每台设备的状态,第二份处理补传与丢帧;跑完之后再决定要不要出门。

一、判据先写死,脚本才有意义

把设备健康压成一个「在线 / 离线」布尔值,后面所有排查都会卡住。至少要拆成两路判据,各出各的告警:

  • 会话级:只看心跳帧。规范里能查到的锚点是 1.5 倍 Keep Alive:客户端承诺每个间隔内保证有控制报文发出,服务端若一个半间隔内一个报文都没收到,就有权直接断掉连接;Keep Alive 填零等于关掉心跳,这种链路不能拿会话级判据。
  • 数据级:只看业务帧。会话活着而采集循环死了,心跳照发、平台照样绿灯,业务数据一条没有。

两路一旦压进同一条告警,平台判定规则自己造出来的误报就再也拆不出来。这是代价最高的一类错误:它花掉的不是工时,是队伍对告警的信任。

状态取五个值,比布尔值多出来的三个都有明确用途:never_seen是从没收到过任何帧(安装或组态问题,不是运行故障)、silent是会话在而业务停、halfopen是会话超时过又自己回来且设备没重启。这三个值一旦混进「离线」,处置方向就是反的 —— 拿备件去换一台根本没坏、只是没配上线的设备。

二、脚本一:事件流算出每台设备的状态

输入按事件流给(设备号、帧类型、时刻),判定器只做纯计算,不碰数据库。好处是可回溯:同一段历史事件今天算一遍、下周再算一遍,结论必须一样。

fromdatetimeimportdatetime,timedelta HB_INTERVAL=300# 心跳间隔(秒),与设备侧配置一致SESSION_TIMEOUT=int(HB_INTERVAL*1.5)BIZ_SILENT=1800# 业务静默阈值(秒)defverdict(devices,events,now):"""events: [{"dev": str, "kind": "hb|biz|reboot", "ts": datetime}]"""buckets={}foreinevents:buckets.setdefault(e["dev"],[]).append(e)result={}fordevindevices:evs=buckets.get(dev,[])hb=sorted(x["ts"]forxinevsifx["kind"]=="hb")biz=sorted(x["ts"]forxinevsifx["kind"]=="biz")rebooted=any(x["kind"]=="reboot"forxinevs)ifnothb:# 一帧都没有:装错或没配result[dev]="never_seen"continueif(now-hb[-1]).total_seconds()>SESSION_TIMEOUT:result[dev]="offline"continuehole=max(((b-a).total_seconds()fora,binzip(hb,hb[1:])),default=0)ifhole>SESSION_TIMEOUTandnotrebooted:# 断过又自己回来,且无重启证据result[dev]="halfopen"continueifnotbizor(now-biz[-1]).total_seconds()>BIZ_SILENT:result[dev]="silent"continueresult[dev]="online"returnresultdefat(h,m):returndatetime(2026,10,25,h,m)defhbs(dev,minutes):return[{"dev":dev,"kind":"hb","ts":at(20,m)}forminminutes]defbiz(dev,minutes):return[{"dev":dev,"kind":"biz","ts":at(20,m)}forminminutes]now=at(20,30)events=[*hbs("A",range(0,31,5)),*biz("A",range(0,31,5)),# 心跳与业务都在*hbs("B",range(0,31,5)),{"dev":"B","kind":"biz","ts":at(19,20)},# 心跳在,业务停在 19:20*hbs("C",[0,10,20]),# 最后一帧 19:20*hbs("D",[0,5,25,30]),# 中间空了 20 分钟,没重启]res=verdict("A B C D E".split(),events,now)fordevin"ABCDE":print(dev,res[dev])# expect: A/B/C/D/E 依次为 online silent offline halfopen never_seenassert[res[d]fordin"ABCDE"]==["online","silent","offline","halfopen","never_seen"]print("A/B/C/D/E 依次为 online silent offline halfopen never_seen:",[res[d]fordin"ABCDE"])

想把C改判成别的状态,前提只有一个:能拿出会话没有超时的证据。日志里搜不到「连接异常」并不等于没断 —— 链路上的中间节点会按空闲时长回收会话映射,回收时不发复位报文,两端各自以为链路还在。这种现象落到脚本里就是halfopen,用户看到的是「平台说离线、设备说自己在线」,两边都没说错。

三、状态到动作:一张映射表

判定结果本身不解决问题,它只决定下一步查哪一层。按状态分流,比按设备逐个猜要快:

判定结果先查哪里需要的证据
never_seen组态与安装设备号是否在平台登记、现场是否上电过
offline供电与上行链路电压曲线、重连时间戳、信号质量
halfopen会话与中间设备未确认报文计数、链路抖动记录
silent设备侧采集任务采集任务最后一次成功落库的时间
online业务规则与解析报文解析失败计数、入库过滤日志

表的最后一列是重点:每一格都要能回答是或否,答完得排除掉一半分支。只能给出「疑似」的检查不是判据,写进表里也只是把几个猜测排了个队,最后还是靠换件试。

四、脚本二:补传去重与序号查缺

断网恢复后成批补传,说明网关把数据缓存住了,这是功能在起作用,不是故障残留。但补传会带来两个新问题:重复帧,以及被填平的时间缺口 —— 只看总条数会误判成「数据完整」。

fromdatetimeimportdatetime LATE=3600# 迟到容忍窗口(秒),超出只计完整性、不进实时曲线defdedup(rows):"""幂等键 = 设备号 + 采集时刻;重复帧保留入站更晚的那条。"""keep={}forrinrows:key=(r["dev"],r["collect_at"])ifkeynotinkeeporr["recv_at"]>keep[key]["recv_at"]:keep[key]=rreturnsorted(keep.values(),key=lambdar:(r["dev"],r["seq"]))defholes(rows):"""按序号找缺号。补传能把条数补齐,序号才指出中间缺过哪一段。"""seqs={}forrinrows:seqs.setdefault(r["dev"],[]).append(r["seq"])gaps={}fordev,sinseqs.items():s=sorted(s)gaps[dev]=[(a+1,b-1)fora,binzip(s,s[1:])ifb-a>1]returngapsdefat(h,m):returndatetime(2026,10,25,h,m)rows=[{"dev":"X","collect_at":at(20,0),"recv_at":at(20,0),"seq":1},{"dev":"X","collect_at":at(20,0),"recv_at":at(20,4),"seq":1},# 重复帧{"dev":"X","collect_at":at(20,5),"recv_at":at(20,5),"seq":2},{"dev":"X","collect_at":at(20,10),"recv_at":at(20,10),"seq":5},# 3、4 丢了{"dev":"X","collect_at":at(20,15),"recv_at":at(21,20),"seq":6},# 补传,迟到]clean=dedup(rows)late=sum(1forrincleanif(r["recv_at"]-r["collect_at"]).total_seconds()>LATE)print(f"去重后{len(clean)}条,X 缺 (3,4),迟到{late}条")# expect: 去重后 4 条,X 缺 (3,4),迟到 1 条assertlen(clean)==4assertholes(clean)["X"]==[(3,4)]assertlate==1

两张表要同时落库:collect_at判数据完整性,recv_at判离线,两者相减是端到端时延。把三个时间揉进一个字段,一批补传数据就全记在恢复通信那一刻,完整率永远算不准。加时钟偏差得注意一个副产品:网关与平台的时间差可以直接算出来,这个差值持续变大,通常比设备真正掉线早出现。

五、三个参数怎么定

  • 会话超时取 1.5 倍心跳间隔,这是规范给的余量,不要自己拍一个「六分钟」;心跳五分钟、阈值六分钟,丢一个包就上下线一次,告警列表一天几百条,真故障全被淹了。
  • 业务静默阈值要跟着业务周期走,不能等于心跳周期。土壤墒情半小时一采,阈值低于半小时等于每轮都报警。
  • never_seen必须单列。新装设备在上线前本来就该是「从未见过」,混进离线告警,运维每天都在处理一批不存在的故障。

参数装在配置里,别散在代码里。换一个棚区就是换一份配置,脚本不用动。

六、四个坑

  • 坑一:拿心跳当数据。有心跳只能说明网络栈没死、定时器还在走;负责读传感器的那个线程已经退出,心跳也不会少一个。设备是否真的在工作,只能看采集循环最后一条成功记录的时间。

  • 坑二:只信一侧时间。只信平台入站时刻,补传数据会挤在恢复那一刻,曲线出现假尖峰;反过来只信设备时间,野外设备多数没有 RTC 电池,一断电重启时钟就退回到默认起点,补上来的整段数据落不进任何统计窗口。

  • 坑三:判据合并。会话级与数据级合成一条告警,等于把两类故障压成一个名字,之后想拆已经没有数据了。

  • 坑四:把补传当故障。恢复后出现成批数据是缓存生效的正常结果,该看的是队列深度有没有回落、存储余量是否见底。

排查顺序上有一句经验:先看时间戳的形态,再看日志明细。同一时刻整片掉,问题在汇聚点;先后错开各掉各的,落回各自的供电与设备;成组但只停在同一总线上,看现场总线。这条判断只要一组时间戳,通常能砍掉一半分支。

脚本只负责把范围缩小到一层,最后一公里的电压与接线还是要到地里量。现场侧的协议适配与断网缓存,交给 Hoyotech 的边缘网关来做:接入一个新厂商,改的是网关的驱动与点位映射,平台那套判定逻辑不动 —— 否则现场总线那一层永远只有设备厂商能查。

代码都在 Python 3.10 以上跑通,直接复制粘贴即可验证;换成自己的数据时,把hbs、biz、rows三处构造替换成从日志或数据库读出来的事件即可,判定逻辑不用改。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询