Skip to content

GET /api/miot/devices/{did}/status 把「上次已知值」按「当前值」的形状端出来 —— agent 会把它当事实断言给用户 #484

Description

@LeonJoeeee

一句话根因:每一层存的都是「上次已知值」,端出去时却按「当前值」的形状呈现 —— 「没读到设备」和「设备就是这个值」在数据上不可区分。

1. 现象

一条 cron:「主卧床头灯还开着就用音箱播报睡眠提醒」。2026-08-02 凌晨每 30 分钟播报一次,把睡着的人吵醒。同一夜、同一台设备,写路径响亮失败,读路径静默成功:02:00 起音箱自己也离线,写路径立刻 -704042011 / success=0 落台账,读路径全程 code:0

8 次触发里 6 次 trajectory 可解析,6/6 都先跑了 device list、拿到 offline、复述了 offline,然后照样相信读数:

ASSISTANT: "The light is showing as \"offline\". Let me still try to check its status..."
RESULT:    {"code":0,"message":"Device status retrieved successfully","data":
            {"properties":[{"iid":"prop.2.1","value":true,"code":0}]}}
ASSISTANT: "The light is ON (value=true). So I need to play a reminder..."

不是没判断,是判断了、判反了 —— 响应体自我描述为权威。而它是在遵守本仓库文档:SKILL.md:193/:179/:137 全是写路径语义,异常表没有一行提到 properties[]

2. 复现

33 台设备、6 台离线,全程只读。云端原始行带 updateTime,后端丢掉了;同一时刻直连:

设备 online ds=1(miloco 走的) ds=2(读真机)
加湿器 false code:0,182.4 天前 -704042011
净饮机 true code:0,25.1 小时 code:0 updateTime=now

在线设备也会给出一天前的读数 —— 「在线/离线」不是判据轴,「多老」才是。 离线设备也不是一律 code:0:电饭煲同一次查询里 10 条缓存命中 code:0、5 条云端自己报 -704042011

3. 根因(文件:行)

  • backend/miloco/src/miloco/miot/service.py:1015-1026 —— 逐属性只取 value/code,同一行 r 里的 updateTime 被丢弃。这是全后端唯一返回属性值的 return。
  • backend/miot/src/miot/cloud.py:849 —— 全仓唯一一处硬编码 {"datasource": 1},无注释。实测 ds=1=云端缓存,ds=2=读真机(不可达设备由云端自己返回 -704042011)。纯透传,miloco 侧无本地缓存。
  • plugins/skills/miloco-devices/SKILL.md:193 —— 文档只覆盖写路径,等于在训练 agent 相信「没报错 ⇒ 值是活的」。

4. 影响面

全仓读设备属性只有 3 处:

  • miot/service.py:1015 → CLI device props → agent:事故路径。CLI 输出没有任何新鲜度字段,而 device list 有 online。
  • rule/runner.py:1336 幂等预检:后果更重 —— 陈旧值 == 目标值 → 报 result=True 跳过,而这个 early return 在所有 _write_action_ledger 之前 → 动作永久丢失、台账无痕。断电后缓存停在 on,规则「开灯」永不重试。
  • miot/client.py:1376 read_cameras_awake:已正确处理(code != 0 → None 三态),现成样板。

WebUI 靠 real.ts:850if (!d.online) 躲过(#441);#290 疑似第三个受害者;静态规则 condition 不读属性,不受影响。

5. 正面回应「这不该在 miloco 修」

  • code 是云端的执行码,本地不该伪造」 —— 成立,正是 fix(cli): treat only negative MIoT result codes as device failure #394 的立意。所以修法一个字节都不伪造:ds=2 下的 -704042011 是云端自己返回的。
  • 「那就加个在线态闸门」 —— 当场抓到反例,接受为设计约束:33 台两次逐台比对漂移 1 台,恰好是床头灯(cached=False / cloud=True,真机读它是活的、亮着的)。非相机设备既无周期刷新也不订阅上下线(client.py:884)。危害不对称:今天的假阳,online 闸门换来的假阴静默。所以只能 additive 加信息,不能拿 online 当判决。
  • 「该在 openclaw 那条 cron 里改」 —— cron 已停,但 rule/runner.py:1336本仓库内,同一个 bug。

6. 建议改法

  1. 透出云端的 updateTime —— 逐属性 updated_at(缺失给 null),CLI 换算 age_s;它是 as-of 注解,不是「设备还活着吗」。
  2. datasource 提成参数,默认仍 1;传了 iid 的定点查询走 ds=2,全量冷查询走 ds=1(复用已有的 user_specified;WebUI 不传 iid,留在便宜的缓存读上)。事故 cron 传了 iid,零改动即判据为假。
  3. rule/runner.py:1336 单独传 ds=2 —— /status 的修复不会惠及它;已有的 code == 0 判据无需改动。
  4. SKILL.md 收窄离线口径到控制路径、补查询侧规则(文档是近因)。

⚠️ ds=2 有批量悬崖,必须分批。 约 4.2s 服务端截止时间且全或无:属性数超过该设备能在时限内答完的数量,整批返回 -704220043、不带 updateTime。悬崖位置因设备而异、对同一台设备确定:

空调    14 个 ok(369ms) → 15 个整批失败(4374ms)
净化器  16 个 ok(857ms) → 20 个整批失败(4739ms)
洗衣机  16 个 ok(368ms) → 20 个整批失败(4179ms)

而端点收逗号分隔的多个 iid(router.py:316),33 台里 11 台可读属性 ≥15 个 —— agent 一次点名就会把一台健康在线的设备整台报成「读不到」,把响亮的假阳换成静默的假阴。按 8 分批后 42/42 全 code:0,还比不分批更快(1758ms vs 4198ms 后整批失败)。

成本(实测): ds=2 在线 15 属性 167249ms(ds=1 为 61ms)、离线 6790ms(云端秒拒,不等超时);30 台并发 290ms vs ds=1 的 356ms,未触发限频。

被否掉的: 离线时改写 properties[].code —— 伪造云端没返回的码、判据是不可信的缓存 online、还会覆盖云端逐属性给出的真实结果。

7. 诚实的开放问题

  1. ds=2 会不会唤醒电池 / 休眠设备并耗电,没测(本机 6 台温湿度计全在线,无可测样本)—— 这是推荐方案最可能在别的部署里出问题的地方。
  2. updateTime 的精确云端语义无文档,我的解读(= 该值上次被写入的时刻)是从三个观察反推的,所以只拿它做 as-of 注解。
  3. 事故当晚灯在不在线无法回溯,「灯当晚真开着、判据本就该为真」的可能我没排除;online 漂移也只有两个时刻的快照。但 ds=1 的读数在两种情形下都无法自证。
  4. last_online 只有 WiFi 设备有 —— cloud.py:716-731 只取了 isOnline,而云端对蓝牙 / mesh 设备不提供 last_online。可用 max(逐属性 updated_at) 替代:实测与 WiFi 设备的 last_online 逐台吻合(加湿器 182.9 天、空调 5.5 天、电饭煲 3.7 天),并且能给出 mesh 驱蚊器的 21.6 天。
  5. 未重新检索上游是否已有同类 issue。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions