智慧农业

农业物联网设备离线怎么排查:从心跳到网关的七层定位法

行业背景:这个问题现在为什么被反复问到

野外的在线率,和机房的在线率不是同一道题。 农业物联网的设备装在没有常电、没有稳定网络、也无人值守的地里,断网与掉电是常态而不是异常:太阳能板被连阴天压住、基站在雷雨夜里失联、网关电池撑不到第三个冬天。机房与工厂的运维流程默认前提恰好相反:电不断、网是通的、出事有人当场看见。把那一套搬进田间,第一步就走错。

衡量标准从「建了多少」换成了「在线多久」。 建设期的验收表上写的是装了多少台设备、接了多少路传感器;进入运维期,同一批人被问的换成三个数:在线率、数据完整率、故障响应时长。指标一换,排障能力就从「功能是否齐全」变成了运维成本的主要变量——设备装得越多,离线告警的绝对数量越大,处置它们的人却没有同比例增加。

「离线」是症状名,不是故障名。 平台界面上只有一行灰字:设备离线。它可能是供电不足、设备重启、现场总线上的从站不应答、网关上行队列堵住、无线链路整片中断、接入会话半开,也可能是平台自己的判定规则算错了。七种互不相干的故障在界面上长得一模一样,而要处置它们所需的人、备件与到场方式完全不同。用同一个名字叫它们,等于把「判断」这一步省掉了。

成本结构决定了排查顺序。 一次平台查询是分钟级,一次到场是半天级——路程、爬杆、拆机、复装,雨季还要再排一次。两者的代价差着一到两个数量级,所以排障的第一原则不是「查得全」,而是「在到场之前把范围缩到一层」。规范里已经给了现成锚点:MQTT 3.1.1 规定,Keep Alive 非零时客户端须在每个间隔内至少发送一个控制报文,服务端在 1.5 倍间隔内收不到报文就断开连接,为零则表示关闭心跳。这条规则是「会话是否还活着」唯一有规范依据的判据。

把「离线」拆成七层,不是为了画一张漂亮的图,而是为了让每一次排查都能用一次判断砍掉一半分支。

痛点:现场到底卡在哪几步

先派人,后判断。 告警一响就派单,是最常见也最贵的反应。人爬到杆上、拆开机箱、量一圈电压,设备好好的——真正出错的是平台那条判定规则:夜里没有业务数据上报本属正常,却被算成了离线。代价不只是半天路程和一箱备件,还有一次「狼来了」之后队伍对告警的信任流失:错误告警多了,真的离线反而没人第一时间动。

把心跳间隔当离线阈值。 心跳五分钟一次,离线阈值就设成六分钟,于是丢一个包就上下线一次。告警列表一天几百条,真正的故障淹没在里面没人看见。规范给的余量是 1.5 倍 Keep Alive,而且会话是否活着与业务数据是否还在来是两道必须分开的判据:前者只看心跳帧,后者只看业务帧。合成一条「设备离线」,就等于把两类完全不同的故障压成同一个名字,之后再想拆开已经没有数据了。

只信一侧时间。 平台的入站时刻好取,设备端的时间却常被忽略。只信前者,断网期间缓存在网关里的数据会在恢复那一刻挤成一堆,曲线出现一个假的尖峰;只信后者,设备没有 RTC 电池、重启后时钟跳回旧日期,补上来的整段数据排不进任何一个统计窗口。两个时间都要存:判离线用平台入站时刻,判数据完整用设备采集时刻,算端到端时延用两者之差。

查完不留证据。 到场一次、拍一张照片、口头给个结论,问题算解决了,下次同类故障一切从零开始。野外的故障复现率很低——可能是某个阴天的低电压,也可能是一段链路的瞬时抖动,错过就再也不会原样出现。留下报文、日志时间戳、电压曲线,结论才可复核,也才有资格进入下一次的判断。

这四个动作用的是同一种思路:拿一个状态名当成了一个故障。省掉的判断,最后都由路上那半天补回来。

方案:可落地的结构与取舍

分层按「谁处置」切,不按技术栈切。 设备、网络、平台三分法看着整齐,却落不进工单系统:一次「网络问题」可能归场内运维,也可能归链路提供方,还得有人先把该找谁判断出来。改按「故障发生在哪一段、由谁带着什么备件到场」来切,同一段内的处置人、工具、到场方式相同,跨段就完全不同——这才是能压成本的分界。按这个口径切出来是七层(L1 到 L7),排查顺序与编号相反:从 L7 的平台判定一路问到 L1 的供电,也就是从最便宜的一步走到最贵的一步。

层典型现象单一判据
L7 平台判定告警反复刷屏;单台设备一天数次上下线;白天在线、夜里离线心跳帧与业务帧的时间线分开看,哪一路先断
L6 接入会话平台收到离线通知,设备端却认为自己在线会话是否还存在;QoS1 未确认的报文是否在堆积
L5 承载链路一个区域或一个网关下的设备整片同时掉,恢复后成批补传网关重连的时间戳分布,配合信号质量与重连次数
L4 网关运行该网关下所有设备一起掉;重启后大量补传;本地时间漂移进程存活、上行队列深度、存储余量、与平台时钟的偏差
L3 现场总线同一条总线上的几台设备成组掉,其余正常轮询日志的从站应答率与超时、异常码分布
L2 设备自身单台反复重启;或心跳正常而业务数据停了重启与看门狗计数;采集循环最后一次写记录的时刻
L1 供电固定在日落前后掉线、日出后自愈;阴雨天连续离线电压曲线的趋势,以及低电模式下通信是否被关掉

第三列才是分层的目的。 每格都要能独立回答「是/否」,答完必须能排除一半分支;只能给出「疑似」的检查不算判据,写进表格也只是把七个猜测排了个队。

L6 常被当成玄学,其实多半是地址转换的会话老化。 中间设备对空闲会话有老化时间,超时即丢弃映射,两端都没收到复位报文,设备侧连接还停在已建立状态——心跳照发,对端早已不存在。于是平台说它离线、设备说自己在线,两边都没撒谎。判据只能是接入侧的会话状态与未确认报文计数,不是「设备日志里有没有报错」。

L4 的上行队列最像故障,其实常常是功能在起作用。 恢复通信后出现成批补传,说明网关把断网期间的数据缓存住了,这是它本该做的事,不是故障残留。真正的判据是队列深度有没有在恢复后回落、存储余量是否见底、网关时钟与平台差多少;只确认「网关能 ping 通」就排除网关,等于没查。

L2 的心跳与采集是两个独立任务。 心跳活着只证明网络栈和定时器还在跑;采集循环挂了,心跳照发、平台照显示在线、数据一条没有。所以设备侧的判据不是「有没有心跳」,而是「采集循环最后一次写出记录是什么时刻」。

成组与单点,是最省时间的一次判断。 同一时刻整片掉,汇聚点在故障,往上查 L5 或 L4;先后错开几分钟、各掉各的,分别落到各自的 L1 或 L2;成组但只停在同一总线上的那几台,看 L3。这条判断只需要一组时间戳,不需要任何现场信息,却通常能砍掉一半分支。

七层不是七次到场,是七次查询。正常情况下 L7 到 L4 在平台上就能答完,只有 L3、L2、L1 才需要有人带着工具去地里。

技术实现:字段、接口与数据口径

七层要能被执行,前提是数据模型里给排障留了位置。三件事必须在建模阶段定死:状态不能只有两个值,时间不能只有一个,判据不能混成一条。

状态机先定死五个值。 never_seen 表示从未收到过任何一帧,它和离线是两回事——前者是安装或配置问题,后者是运行问题,混成一个值,新装设备的告警就会一直挂着。online 是会话在、业务数据也在走;silent 是会话还在而业务帧停了,处置人看设备侧的采集任务;halfopen 是会话曾经超时、设备并没有重启却又自己恢复,信号抖动和地址转换表的会话老化常落在这里;offline 是会话确实超时。进入 silent 用业务静默阈值,进入 halfopen 或 offline 用 1.5 倍心跳间隔的会话超时,两者的区别只在恢复之后有没有重连或重启的证据。

三个时间戳,各答各的问题。 设备采集时刻、网关收下时刻、平台入站时刻要分别落库。判离线看平台入站时刻,判数据完整性看设备采集时刻,算端到端时延用两者相减。补传场景下把三个时间揉进一个字段,数据完整性就永远算不准——一批补传数据会全部记在恢复通信的那一刻。副产品是时钟偏差:网关与平台的偏差可以直接算出来,偏差持续变大就在 L4 真正出事之前先报警。

判据不合并。 会话级只看心跳帧,数据级只看业务帧,两条告警各自独立、各自通知。合成一条「设备离线」之后,L7 那类判定规则造成的误报就再也分不出来。

序号要单独存一列,丢帧才看得见。 上行帧带单调递增序号,缺号即丢帧;补传之后按序号再对一遍。只看条数会判「数据完整」——补传恰好能把条数补齐,序号才能指出中间缺过哪一段。

判定器按事件流算,不查数据库:


from dataclasses import dataclass
from datetime import datetime, timedelta

KEEPALIVE = 300          # 心跳间隔(秒)
SESSION_TIMEOUT = 450    # 1.5 x keepalive,会话超时
BIZ_SILENT = 1800        # 业务静默阈值(秒)


@dataclass
class Frame:
    dev: str
    kind: str            # heartbeat / data / reboot
    ts: datetime


def judge(frames, devices, now):
    """每台设备返回 (状态, 依据)。判据分层,不合并成一条。"""
    out = {}
    for dev in devices:
        evs = sorted((f for f in frames if f.dev == dev), key=lambda f: f.ts)
        if not evs:
            out[dev] = ("never_seen", "从未收到任何帧")
            continue
        hb = [f.ts for f in evs if f.kind == "heartbeat"]
        data = [f.ts for f in evs if f.kind == "data"]
        gaps = [(b - a).total_seconds() for a, b in zip(hb, hb[1:])]
        if not hb or (now - hb[-1]).total_seconds() > SESSION_TIMEOUT:
            out[dev] = ("offline", "心跳中断超过会话超时")
        elif gaps and max(gaps) > SESSION_TIMEOUT and not any(f.kind == "reboot" for f in evs):
            out[dev] = ("halfopen", "会话曾超时,设备未重启即恢复")
        elif not data or (now - data[-1]).total_seconds() > BIZ_SILENT:
            out[dev] = ("silent", "会话在线但业务帧静默")
        else:
            out[dev] = ("online", "心跳与业务帧都在窗口内")
    return out


def at(h, m):
    return datetime(2026, 10, 25, h, m)


now = at(20, 30)
frames = [
    # A:心跳与业务帧都连着
    *[Frame("A", "heartbeat", at(20, m)) for m in (0, 5, 10, 15, 20, 25, 30)],
    *[Frame("A", "data", at(20, m)) for m in (0, 5, 10, 15, 20, 25, 30)],
    # B:心跳在,业务帧停在 19:20
    *[Frame("B", "heartbeat", at(20, m)) for m in (0, 5, 10, 15, 20, 25, 30)],
    Frame("B", "data", at(19, 20)),
    # C:心跳最后一帧在 19:30,之后没有
    *[Frame("C", "heartbeat", at(19, m)) for m in (0, 10, 20, 30)],
    # D:中间断了 20 分钟又自己回来,且没有重启帧
    *[Frame("D", "heartbeat", at(20, m)) for m in (0, 5, 25, 30)],
]
result = judge(frames, ["A", "B", "C", "D", "E"], now)
for dev in sorted(result):
    print(dev, result[dev][0], "-", result[dev][1])

# expect: 五类判定各命中一次以上
names = "online silent halfopen offline never_seen".split()
counts = {}
for state, _ in result.values():
    counts[state] = counts.get(state, 0) + 1
assert all(counts.get(n, 0) >= 1 for n in names), counts
assert result["A"][0] == "online"
assert result["C"][0] == "offline"
assert result["D"][0] == "halfopen"
print("五类判定各命中一次以上:", {n: counts[n] for n in names})

补传这一段要解决两件事:重复帧和迟到帧。


from dataclasses import dataclass
from datetime import datetime, timedelta

WINDOW = 300            # 实时曲线时间窗(秒)
LATE_TOLERANCE = 3600   # 迟到容忍窗口(秒)


@dataclass
class Sample:
    dev: str
    collect_at: datetime      # 设备采集时刻
    received_at: datetime     # 平台入站时刻
    value: float


def stitch(samples):
    """幂等键 = 设备号 + 采集时刻;重复帧保留入站更晚的那条。"""
    best = {}
    for s in samples:
        key = (s.dev, s.collect_at)
        if key not in best or s.received_at > best[key].received_at:
            best[key] = s

    step = WINDOW // 60
    realtime, late = {}, []
    for s in best.values():
        if (s.received_at - s.collect_at).total_seconds() > LATE_TOLERANCE:
            late.append(s)          # 计入完整性,不进实时曲线
            continue
        win = s.collect_at.replace(second=0, microsecond=0)
        win -= timedelta(minutes=win.minute % step)
        realtime.setdefault(win, []).append(s)
    return best, realtime, late


def at(h, m):
    return datetime(2026, 10, 25, h, m)


samples = [
    Sample("X", at(20, 0), at(20, 0), 11.5),
    Sample("X", at(20, 0), at(20, 4), 12.0),   # 重复帧,保留这条
    Sample("X", at(20, 5), at(20, 5), 12.4),
    Sample("X", at(19, 0), at(20, 20), 10.9),  # 迟到 80 分钟
    Sample("Y", at(20, 5), at(20, 6), 21.0),
]
best, realtime, late = stitch(samples)
print("去重后", len(best), "条,迟到", len(late), "条,实时曲线",
      sum(len(v) for v in realtime.values()), "条")

# expect: 去重后 4 条
# expect: 迟到 1 条
assert len(best) == 4
assert len(late) == 1
assert best[("X", at(20, 0))].value == 12.0
assert sum(len(v) for v in realtime.values()) == 3
print("补传缝合自检通过")

接口边界收在网关侧。 平台只认一种帧:设备号、帧类型、采集时刻、序号、载荷摘要。用什么协议轮询、怎么组帧、地址怎么编,差异全部收敛在网关。Hoyotech 的边缘网关在这条链路上承担现场侧的多协议适配与断网缓存,平台侧因此不必每接一个厂商就改一次排障逻辑——否则七层里最贵的 L3 永远只有设备厂商能查。

项目实践:一次真实落地怎么走

第一步先归类,不先改配置。 最有效的起点不是调阈值,而是把过去一段时间的离线告警逐条摊开、按七层重新归一次类:靠时间戳的形态、网关的重连与重启日志、补传记录回填每一跳,只归类不优化。产出只是一张分布表,但它常常当场推翻团队原来的判断——被投诉最多的那台设备,往往并不在告警数量最多的那一层;而告警最密集的那一层,多半是判定规则自己造出来的。

第二步改判据,不碰设备。 会话级与数据级两条判据拆开,离线阈值按 1.5 倍心跳间隔重新取值,never_seen 从离线里摘出去单列。一台设备都不用换,告警量就开始往下走。误报降下来之后,运维才第一次愿意相信告警——这件事比任何一条新规则都重要。

第三步把网关侧的可观测性补成数据。 电压、信号质量、重连次数、重启计数、上行队列深度,与业务数据进同一个时序库,采集周期比业务数据更密,一到五分钟一次,断网时本地留存。这一层做对了,L4 与 L1 的故障能在设备真正掉线之前开出工单,而不是掉线之后再猜。Hoyotech 的边缘网关在这一层承担的就是把这些量按时采上来、断网时先存住。

第四步把判定树写进 runbook。 七层、每层一个判据、一条命令,做成现场人员能照着走的检查单,每步写明「答是怎么办、答否怎么办」。它的价值不在写得多准,而在让不同的人对同一台设备得出同一个结论。

这一轮能说的只有方向:到场次数下降、误报收敛、定位从以天计缩到一次查询。没有实测数字就不写百分比——编出来的数字,是这类文章最容易失信的地方。

价值总结:判断标准与复用边界

衡量这套方法的指标不是恢复速度。 设备多久回到在线,取决于供电与网络,往往跟排查方法没多大关系。更该看三个能从系统里算出来的数:一次工单有多少能在不到场的前提下定位到具体哪一层、同类故障的平均定位时长、告警总量里有多少是判定规则自己制造的误报。第一个往上走、后两个往下走,七层才算真的成了团队的能力,而不只是文档里多张表。

边界要先讲清楚。 七层成立的前提是设备经网关再上云这条三段式链路。设备直接连云的单跳场景,承载链路与网关运行本就是一回事,硬拆成两层,等于把一次排查做成七次走形式;有线或园区网里,整片掉线这一层基本消失,工夫要前移到会话与判定。层数套错的排查,和凭经验猜一样不可靠。

把「离线」当作状态名,就总会在同一条链路上重复踩坑;把它当作七层症状索引,运维才有机会从「到场」变成「查询」。

Smart Agriculture

把智慧农业落到你的现场

围绕种植、设施农业与水产养殖场景,构建环境监测、智能控制与生产数据一体的数字农业体系,把经验种植变成可量化、可复制的数据化生产。