24 KiB
环境监控模块设计(米家温湿度长期记录)
目标项目: iboard(Tauri + React + Express + Prisma/MariaDB) 状态: 设计稿 v1.1 · 2026-09-20 · 待评审(v1.1: 视图新增 今天/月/年三档粒度 + 标题显示电量)
1. 需求与目标
在监控页新增「环境监控」tab,实现多家庭下温湿度长期记录:
- 监控页新增一个 tab「环境监控」。
- 多家庭支持:每个家庭一个可折叠 group,手风琴式展开(同一时间只能展开一个)。
- 每个设备可独立修改别名(仅本应用展示用,不改动米家云端设备名)。
- 数据非实时(定时批量,不做推送):每小时采样 1 次落库小时档,每日聚合成日档。
- 记录保留永久(无清理任务)。
- 缺失补齐语义(用户确认):
- 应用停机导致的缺失 → 应用恢复后自动重新获取补齐(有统计的型号经云端聚合真实回填, 见 §2.2B;无统计的型号保持缺口标注);
- 设备自身原因(离线/故障)的缺失 → 保持缺失,不伪造数据;
- 总原则:本地数据不少于米家可提供的数据。
- 米家授权失效 → 前端显示「重新授权」按钮,点击弹出二维码,人工扫码后恢复。
- 每设备数据默认显示当天,粒度小时。
- 每设备数据可切换为月,粒度天。
- 每设备数据可切换为年,粒度周。
- 设备卡片标题 = 别名 + 电量(电量取当天缓存值,不必频繁更新)。
已确认的数据范围:仅温湿度(不含照度 / 人体存在)。
2. 已验证事实(调研结论,作为设计地基)
调研用的账号 18814803393,登录链路与数据能力已实测(脚本位于
C:\Users\xydkj\AppData\Local\Temp\opencode\mibridge-research\):
| 项目 | 结论 | 证据 |
|---|---|---|
| 登录方式 | 密码登录被米家拒绝(70016);扫码登录可行 |
qr-once.mjs |
| Token 有效期 | 30 天,DiskCache 持久化,脚本自动加载刷新 |
.mijia-cache/ |
| 实时读值 | api.getDevicesProp([{did,siid,piid}]) 稳定可用,100 项请求 10 轮 100% 成功 |
env-monitor.mjs |
| 单轮延迟 | p50 ≈ 520ms,p95 ≈ 612ms(10 项属性/轮) | 同上 |
| VRF 空调目标温写入 | 整数生效但生效延迟 5~6s(updateTime 才更新);半度 26.5 被服务端拒收(-704220043) |
vrf-temp-test*.mjs |
| 温湿度历史读取 | 不可读:history-data(8.1)/start-time(8.3) 返回 -704010000;start-synic 动作要求参数;/v2/home/device/prop_history 类端点不可达 |
dev-history-probe.mjs |
2.1 环境设备清单(共 3 台,均属家庭「黄金农场」505001017889)
| 家庭 | 设备 | 型号 | did | 温度 | 湿度 | 电量 |
|---|---|---|---|---|---|---|
| 黄金农场 | 炎米智能温湿度计 | ym001.sensor_ht.ymwsdj |
blt.3.1nmu8afgt0g00 |
2.1001 | 2.1002 | 3.1003 |
| 黄金农场 | 炎米智能温湿度计 2 | ym001.sensor_ht.ymwsdj |
blt.3.1nmu8eunt0001 |
2.1001 | 2.1002 | 3.1003 |
| 黄金农场 | 闹钟(青萍,含温湿) | cgllc.clock.dove |
blt.3.12cq3lu8glo00 |
2.1 | 2.2 | 2.3 |
第二个家庭「韩家」(586001550228) 当前无环境设备,设计需容忍空家庭。
2.2 关键约束:逐点历史不可达,但云端「聚合统计历史」可用(实测反转)
原结论"云端历史完全不可达"已推翻。实测分两条:
A. 逐点历史(设备本地日志)云 API 不可达
- 炎米温湿度计:规格含
8.1 history-data / 8.3 start-time / start-synic,但 8.1/8.3 读取全返回-704010000(不可读),start-synic动作试了 8 种参数形态全部报-704220025 参数个数不匹配, 判定为"局域网直连通道",云侧无暴露; - 青萍闹钟:miot 规格仅 temperature / relative-humidity / battery-level,无任何历史属性/动作。
B. 云端聚合统计历史「可用」(%@zythum02/mijia-api 可直接打 POST /v2/user/statistics)
参数 { did, key(siid.piid), data_type, limit, time_start, time_end },用本库 api.request() 直连即可。
| data_type | 桶(Asia/Shanghai) | value 语义 | 实测(炎米 ym001.sensor_ht.ymwsdj) |
|---|---|---|---|
stat_hour_v3 |
1 小时整点 | 小时均值 | 连续入库(炎米1 有部分缺档),如 [28.8] |
stat_day_v3 |
1 天 00:00 | 当日 [min, max] | 45 天全有,如 [28.1, 29.2],与实时读值吻合 |
stat_month_v3 |
1 月 | 当月 [min, max] | 回溯到 2026-01(联网注册后云端一直记账) |
stat_week_v3 |
1 周 | — | 该型号返回 -8 invalid params(未开通周档) |
| 回补深度 | — | — | 两台炎米均回溯到接入米家日起(实测到 2026-01-03);缺席日=设备当天无上报 |
- 青萍闹钟
cgllc.clock.dove与 VRFproxy.624017060.00001全部 data_type 报-8 invalid params→ 云侧未为其开通统计(青萍与其无历史服务一致)。 - 证据:
mibridge-research/stats-probe.mjs/stats-probe2.json。
因此:
- 停机缺口可补真值:对开通统计的型号(炎米等),停机恢复后用
stat_day_v3回填缺日 [min,max]、stat_hour_v3补小时均值,origin=vendor_history(见 §5.3); - 「精确到某个刻的逐点值」仍不可得(那是设备本地日志,须走局域网 BLE/网关,见 §10);
- 未开通统计的型号(青萍)维持「当天实时采样 + 缺口标注」。
3. 总体方案
- 后端新增
server/mihome/模块,封装与米家的认证 + 采集 + 查询; - 依赖新增
@zythum02/mijia-api(扫码登录、getDevicesProp、DiskCache), 认证缓存文件用DiskCache存于服务端磁盘(.cache/),与采集进程同机; - 每小时采样 1 次写小时档(
MihomeEnvHour),每日 03:00 (Asia/Shanghai) 聚合日档 (均值/min/max)+ 读一次电量;服务启动时做追赶同步(缺小时/缺天则按序补齐), 停机跨天的历史缺口由vendorHistoryFiller用/v2/user/statistics真实回填(有统计的型号); - 前端
MonitorPage增加 tab,antd-mobile Collapse(手风琴)+recharts趋势图, 别名用Dialog.Prompt编辑;每设备卡片标题=别名+电量,展开后Segmented切换 今天(时)/月(天)/年(周) 三档视图; - 数据永久保存,小时档按
(deviceId, hour)、日档按(deviceId, date)唯一,天然防重。
4. 数据模型(Prisma)
// ===== 环境监控:米家 =====
// 米家授权状态(DiskCache 存凭证, 本表只存状态/错误供前端展示)
model MihomeAccount {
id Int @id @default(autoincrement())
key String @unique @default("default")
userId String @default("")
status String @default("unknown") // ok | pending | expired
lastError String @default("")
updatedAt DateTime @updatedAt
}
model MihomeHome {
id Int @id @default(autoincrement())
homeId String @unique
name String
createdAt DateTime @default(now())
devices MihomeDevice[]
}
model MihomeDevice {
id Int @id @default(autoincrement())
did String @unique
homeId String
home MihomeHome @relation(fields: [homeId], references: [homeId])
model String
name String
alias String? // 用户别名, 仅本应用展示
tempSiid Int
tempPiid Int
humSiid Int
humPiid Int
battSiid Int // 电池电量属性(如炎米3.1003 / 青萍2.3)
battPiid Int
battery Int? // 最近一次电量(每天读一次, 标题展示用)
batteryAt DateTime? // 电量读取时间(当天缓存, 不频繁更新)
enabled Boolean @default(true)
lastSyncAt DateTime?
createdAt DateTime @default(now())
samples MihomeEnvSample[]
hours MihomeEnvHour[]
}
model MihomeEnvSample {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
date DateTime // 采样日(Asia/Shanghai 当日 00:00 存 UTC)
temperature Float? // 当日最后一次 live 值(或 vendor hour 均值)
humidity Float?
temperatureAvg Float? // 当日均值(由小时档聚合, 或 vendor_hour)
humidityAvg Float?
temperatureMin Float? // 当日最低
temperatureMax Float? // 当日最高
humidityMin Float?
humidityMax Float?
origin String @default("live") // live | vendor_history | backfill
sampledAt DateTime?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, date]) // 一天一条, 防重
@@index([deviceId, date])
}
model MihomeEnvHour {
id Int @id @default(autoincrement())
deviceId Int
device MihomeDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
hour DateTime // Asia/Shanghai 整点(存 UTC)
temperatureAvg Float? // 该小时均值(live 采样点聚合, 或 vendor_hour)
humidityAvg Float?
raw Json?
createdAt DateTime @default(now())
@@unique([deviceId, hour]) // 一小时一条, 防重
@@index([deviceId, hour])
}
要点:
- 凭证不进库,只在密钥盘(
server/mihome/.cache/,需加入.gitignore); MihomeHome/MihomeDevice随每次同步 upsert(以米家为准,重扫不漏不错);- 存储分三档:小时档(当天视图,逐小时)、日档(月视图,日均/min/max)、 年视图按日档现场聚合周桶(不落库,见 §5.6);
- 电量只存在
MihomeDevice.battery/batteryAt,每天读 1 次覆盖当天展示用(需求 11); MihomeEnvSample.origin:live——定时采样的真实值(小时档写入,日档由小时聚合);vendor_history——经/v2/user/statistics拉回的云端聚合历史(min/max/hour 均值);backfill——停机恢复后对当天的补记(仍用真实值,与live同字段)。
5. 后端设计
5.1 代码结构
server/mihome/
index.js # 模块入口: 初始化 MijiaAPI + DiskCache, 导出 service
auth.js # 认证管理 + 扫码流程(后台 QRlogin 循环 + pendingQr 状态机)
collector.js # 采集器: 每小时采样 / 每日聚合 / 追赶同步 / 电量 / 设备发现
vendorStats.js # /v2/user/statistics 封装(按型号探测可用性, 提供 hour/day 回填)
history.js # 查询聚合: today(时) / month(天) / year(周)
cache.js # DiskCache 路径与清理
server/routes/mihome.js # REST 路由 (authRequired)
5.2 认证与「重新授权」
- 启动时
login()自动尝试加载/刷新DiskCache凭证;成功则status=ok。 - 失败(30 天过期 / 风控)→
MihomeAccount.status=expired,前端弹授权横幅。 - 重新授权流程(服务端驱动,前端轮询):
POST /api/mihome/reauthorize
└─ 若 pendingQr 空闲 => 后台启动 QRlogin 循环(每轮 120s 超时自动续二维码)
└─ 立即返回 { state, qrImageUrl }
GET /api/mihome/reauthorize/status
└─ 返回 { state: idle|waiting|scanning|ok|failed, qrImageUrl?, lastError? }
└─ 扫码成功(qrlogin 返回) => 保存凭证 => 状态 ok => 触发一次立即同步
POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
pendingQr为模块级单例,防并发(同一时间只允许一个扫码流程)。- 前端在弹窗内
setInterval 3s轮询直到ok,成功后关弹窗并刷新列表。
5.3 采集器 collector
- 预检:
whoami().login不为真 → 置expired,本轮跳过(不产生记录)。 - 设备发现:
getDevicesList()→ upsert 家庭/设备;仅保留具备temperature+relative-humidity属性的型号(经getDeviceInfo/miot-spec 判定, 当前命中ym001.sensor_ht.ymwsdj、cgllc.clock.dove白名单);记录其battSiid/battPiid。 - 小时采样(每小时):批量
getDevicesProp读温度+湿度 → upsertMihomeEnvHour(deviceId, hour=当前整点),写temperatureAvg/humidityAvg(此小时第一个有效点 即视为小时值)。每台独立 try/catch:- 设备离线/超时 → 该小时不写(缺口保持);
- 网络/API 异常 → 整体捕获,本轮不误标,留待下次。
- 电量(每天 1 次):每日首轮采样顺带读
battery-level→ 写MihomeDevice.battery/batteryAt(当天缓存,标题展示用,不频繁更新)。读不到不报错。 - 每日聚合(03:00 + 启动追赶): upsert
MihomeEnvSample(deviceId, date):temperatureAvg/humidityAvg= 当日各小时均值;temperatureMin/Max、humidityMin/Max= 当日小时 min/max;temperature/humidity= 当日最后一次 live 值;- 当日采样小时数 < 8h 且设备在线的 → 判定硬件故障?否:仅当设备整天在线的阈值外仍缺, 视为设备离线缺档(保持缺口,不伪造)。
- 追赶同步(补齐):每次轮询/同步结束后检查——对每个设备 「缺小时档且采集入口健康」→ 按序补齐小时档;「今天就无样本」→ 立即执行每日聚合。 服务启动时触发一轮,实现停机恢复当天即补。
- 历史补齐(vendorHistoryFiller / vendorStats.js):每台设备先探测一次各 data_type
可用性(实测:炎米 ✅ / 青萍、VRF ❌
-8 invalid params),仅对可用型号扫描近 45 天缺口:- 缺小时 →
stat_hour_v3回填MihomeEnvHour.temperatureAvg/humidityAvg; - 缺天 →
stat_day_v3回填日档 min/max,再由小时档均值回填 avg; - 命中即写
origin=vendor_history;对「服务端停机跨天」缺口尤其有效。
- 缺小时 →
- 缺口标记:
GET /status计算每设备最近 30 天缺样本日期列表(missingDays): 今天缺=待采;历史缺=先尝试 vendor 统计补齐,补不了则标注不可得。
补数边界(诚实策略):已开通统计的型号(炎米等)停机跨天缺口可用云端聚合真值补齐 ([min,max]/小时均值,
origin=vendor_history);未开通的型号(青萍)保持缺口标注、不伪造。 「精确逐点历史」仍不可得,只能走局域网 BLE/网关(§10 可选项 C)。
5.4 路由表(均在 /api/mihome,authRequired)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /status |
授权状态 + 家庭/设备(含别名、电量、最新样本、30 天缺口、统计可用性探测结果) |
| GET | /device/:did/history?range=today |
当天逐小时(见 §5.6);缺省 range=today |
| GET | /device/:did/history?range=month |
近 30 天逐日(日均 + min/max) |
| GET | /device/:did/history?range=year |
近 52 周逐周(周均值 + min/max,服务端按日档聚合) |
| PUT | /device/:did |
{ alias } 改别名(可清空) |
| POST | /sync |
手动触发一次采集(前端「立即同步」) |
| POST | /reauthorize |
开始扫码(返回二维码 URL) |
| GET | /reauthorize/status |
轮询扫码状态 |
| POST | /reauthorize/cancel |
取消扫码 |
5.5 定时任务(并入 server/cron.js)
0 * * * *(Asia/Shanghai):小时采样(温度+湿度,每小时整点 +5min 缓冲)。5 0,3 * * *:每日聚合 →battery读取 → vendor 补齐扫描。- 兼容表达式:
npm run data:mihome(手动跑一次采集器)备用。
5.6 历史查询聚合(history.js)
| range | 时间窗 | 粒度 | 数据源/算法 |
|---|---|---|---|
today |
当天 00:00→now | 小时 | MihomeEnvHour 当日行;未来小时返回 null |
month |
近 30 天 | 天 | MihomeEnvSample:temperatureAvg/humidityAvg(折线)+ min/max(区间带) |
year |
近 52 周 | 周 | 按日档现场聚合:每周 avg(日 avg 均值)、min(日 min 最小)、max(日 max 最大),不落库 |
响应统一形如 [{ t, temperature, temperatureMin, temperatureMax, humidity, humidityMin, humidityMax }],
t 为桶起始(Asia/Shanghai),缺数据字段为 null。
6. 前端设计
src/pages/MonitorPage.jsx 的 tabs 增加 { key: 'env', label: '环境监控' },
对应 src/pages/monitor/EnvView.jsx。
EnvView 结构
- 授权横幅(
status !== ok):米家授权已失效/待授权+ 【重新授权】按钮 →Popup内<img src=qrImageUrl>3s 轮询/reauthorize/status→ 成功自动关闭并刷新。 - 顶部操作:
立即同步(POST/sync)。 - 家庭列表:
antd-mobile <Collapse accordion>按家庭分组;组展开后显示该家庭 下的环境设备卡片;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。 - 设备卡片(不可折叠,点击进入详情/趋势):
- 标题:别名(无别名则原名)+ 电量(如
卧室 78%,电量用battery当天缓存值, 不频繁请求);附带编辑别名按钮(Dialog.Prompt→PUT); - 概览:
温度 28.1℃ · 湿度 71%(最新样本)+ 「缺 N 天」暗红角标; - 图表区:
Segmented(antd-mobile) 切换 今天/月/年,默认今天;recharts LineChart双线(温度/湿度)+ 可选 min/max 区间带(Areaband):今天→ X 轴逐小时(00:00→当前),数据/device/:did/history?range=today;月→ X 轴逐日(近 30 天),数据?range=month(日均折线 + 日 min/max 影带);年→ X 轴逐周(近 52 周),数据?range=year(周均值折线 + 周 min/max 影带);
- 视图切换/拉数据留在卡片内部(不用全局 tab),逻辑沿用现有
ServerDetail.jsx/DataView.jsx图表写法。
- 标题:别名(无别名则原名)+ 电量(如
- 样式复用
globals.css的.card/.monitor-*体系,新增少量 collapse 样式。
手风琴用
Collapse accordion;「不同时打开」即一次仅一个家庭展开。
7. 迁移与部署
npm i @zythum02/mijia-api(加入 dependencies)。schema.prisma追加 5 个模型 →npx prisma migrate dev --name add_mihome_env。.env可选MIHOME_CACHE_DIR(默认server/mihome/.cache); 服务端放.cache/于.gitignore。- 首次部署:在服务器上人工跑一次扫码授权(
npm run data:mihome的初始化提示 会输出二维码),写入服务器本地的.cache/。 - 数据库迁移走现有
prisma migrate deploy流程。
8. 风险与开放问题
| # | 风险/问题 | 应对 |
|---|---|---|
| 1 | 逐点历史不可读(设备本地日志),停机期要精确到刻的值补不上 | 已找到云端聚合通道(/v2/user/statistics,min/max/时均值,实测可行);按模型开通情况补齐(炎米 ✅ / 青萍 ❌);精确逐点 → 可选 §10 C |
| 2 | token 30 天过期需人工扫码 | 需求 4 的重新授权流程已覆盖;扫码流程 120s 超时自动换码 |
| 3 | 采样时刻固定凌晨,代表性一般 | 已改每小时采样,当天小时视图/日均/周均代表性充分;03:00 仅做日档聚合 |
| 4 | 第三方 @zythum02 库上游变动 |
锁定版本;模块边界隔离,只暴露我们自己需要的函数 |
| 5 | VRF/其他设备写入延迟 5~6s | 仅影响将来的控制功能,本模块只读,无影响 |
9. 实施步骤(里程碑)
- M1 后端地基:依赖 + Prisma 迁移(5 模型)+
server/mihome(认证 + collector 小时采样/每日聚合/电量 + vendorStats 补齐 + history.js 三段聚合 + 路由)+ cron。 - M2 前端:
EnvView(授权弹窗 / 手风琴家庭组 / 设备卡=别名+电量 / 别名编辑 / Segmented 今天·月·年视图与图表 / 缺口角标)。 - M3 联调:真机/Web 端到端 —— 小时采样与日聚合、停机恢复追赶与 vendor 补齐、 扫码重授权、别名、电量、三个粒度的图表与缺口展示。
- M4(可选,已基本闭环):
/v2/user/statistics云端聚合历史通道已实测打通, 直接并入 M1 的vendorHistoryFiller(无需再抓包 App)。 - M5(可选):可选项 C 家侧本地采样代理(见 §10),用于「精确逐点」与分钟级采样。
10. 可选项 C:家侧本地采样代理 + 缓冲补数
背景:主线「云 API 每日采样」的停机跨天缺口现可由云端聚合统计(§2.2B)补齐到 [min,max]/小时 粒度;可选项 C 是把它做到精确逐点 / 分钟级——家侧一个常驻采集进程(可选配合小米中枢网关), 本地连续采样并缓冲,服务端恢复后拉真值回填。 与主线并行可选,不影响 §1~§9 落地;仅在追求细粒度历史时投入。
10.1 事实依据(已调研)
| 事实 | 结论 |
|---|---|
| 家庭现有网关 | 无中枢/多模网关;blt.* 设备挂在「小爱音箱 Play 增强版」上转发,该音箱不开放局域网 API |
| 局域网直连设备本身 | 需本地 BLE 适配器;青萍闹钟(CGD1)协议已逆向但仅有实时/设置、无历史通道;炎米 ym001 零社区支持,需自行逆向厂商 GATT + 推导 MiKey,把握低 |
| 若有小米中枢网关 | 官方 HA 集成走网关内置本地 MQTT Broker,可本地化 WiFi+BLE 设备、断外网可用、延迟 10~50ms(社区实测 ha_xiaomi_home);但本地 Broker 只推实时 props,不提供历史 |
| 概率评估 | 本地实时读取:无网关走蓝牙 |
10.2 方案
家庭内常驻小机(现有 Windows 机 / 树莓派) 跑 server/mihome-agent/
├─ 采样源(可切换): a) 云 getDevicesProp (同 §5.3, 无需新硬件)
│ b) 加中枢网关后走本地 MQTT(断外网可用, 秒级粒度)
│ c) 未来本地 BLE 嗅探(青萍 CGD1 优先)
├─ 每 N 分钟采样 → 写本地缓冲 SQLite/JSONL(本地保留 180 天)
└─ 提供 GET /samples?from&to&did 供服务端拉取
服务端(iboard)
└─ 停机恢复/每日同步时: 调 agent 拉缺口区间真实值 → 写 MihomeEnvSample(origin=backfill)
(服务端主动拉 agent,agent 无需入公网,可用内网/隧道)
10.3 效果与代价
- 效果:停机期 agent 缓冲持续采样,恢复后以精确逐点/分钟级真值回填(粒度高于 §2.2B 的 聚合统计);加中枢网关后数据链路断外网可用、秒级推送。
- 代价:家中常驻进程(可复用现有 Windows 开发机);可选购买中枢网关(~¥349); agent 与服务端的传输通道需内网/VPN/隧道。
- 决策点:是否投入属产品决策。当前「补齐停机缺口」已被 §2.2B 的云端统计覆盖(满足
「数据不比米家少」);C 只对「要更细粒度」有增量价值。接入点:agent 值写入
MihomeEnvSample,origin=backfill。 - 本地直连协议现状(中枢/蓝牙):官方 HA 集成走中枢内置 MQTT Broker(需 HA 或移植其
MipsLocalClient的证书鉴权+微链路,无公开协议,自有 Node agent 直连把握中低);纯 BLE 嗅探见 §10.1 表格(青萍 CGD1 有逆向基础、炎米零资料)。