纳入范围: - backend/ Spring Boot 多模块后端(iot-common/device/rule/data/alarm/auth) - frontend/admin/ Vue3 + Element Plus 管理后台 - Android/ 涂鸦原生 APP (com.xhai.iot) - backend/sql/ Flyway 风格迁移脚本(V2~V21) - docs/ 对接文档与设计文档 排除范围: - 构建产物: node_modules/ target/ build/ dist/ .gradle/ - 签名密钥: *.jks *.keystore (严禁入库) - 本机配置: Android/local.properties - 运行日志: logs/ *.log - AI 协作本地数据 .codebuddy/ 与第三方 demo tuya-demo/ .gitattributes 统一以 LF 存储, Windows 批处理保留 CRLF
11 KiB
11 KiB
设备操控动态(Activity)统一方案
目标:以 APP 自己的控制 + 监听到的信息为第一数据源形成操控动态,再逐步对接各平台(涂鸦/米家/华为/Matter)的云端动态。 编写日期:2026-09-20 | 状态:方案待评审
1. 现状盘点
1.1 已有资产
| 资产 | 位置 | 说明 |
|---|---|---|
device_activity_log 表 |
iot-device | 动态存储,字段见 1.2 |
DeviceActivityService |
iot-device/service | record() 异步写入、hasRecentActivity() 查重、buildDpsDesc() 描述生成 |
DeviceActivityController |
iot-device/controller | GET /devices/activities/recent(首页)、GET /devices/activities(分页) |
| 物模型 / 标准控制模型 | product + product_property + ControlSchemaService |
可把原始 DP 翻译成人类可读语义(P3 已落地) |
| 平台抽象层 | PlatformServiceManager / IPlatformService |
多平台统一控制入口 |
1.2 当前表结构
id, home_id, owner_id, device_id, device_name,
action, action_desc, source, operator_member_id, detail, create_time
1.3 现有记录点(4 个)
| # | 位置 | 触发 | source |
|---|---|---|---|
| 1 | TuyaController.sendCommand |
经后端下发的指令(云端通道) | app / voice |
| 2 | TuyaController.syncDeviceStatus(/tuya/devices/status) |
APP 上报 DP 变化 | panel |
| 3 | TuyaMessageSubscribeService |
涂鸦云端推送(Pulsar) | cloud_push |
| 4 | DeviceActivityController.report(/devices/{id}/activity,本期新增) |
APP 走 SDK 直连后主动上报 | app |
1.4 已暴露的问题
- APP 走 SDK 直连,后端天然感知不到:控制不经过后端
/commands,动态全靠"上报"补(记录点 4 是补丁,不是终态)。 - 语义弱:
buildDpsDesc()只识别 bool,输出打开/关闭 设备名;不懂物模型,亮度/模式/枚举都只能输出"状态更新(N 项)"。 - 来源语义重叠,易双记或漏记:一次真实操作常同时触发 记录点 2 和 3,只能靠 8 秒查重粗略兜底。
- 平台耦合:涂鸦专属逻辑(Pulsar 解析、
/tuya/...接口)散落在TuyaController、TuyaMessageSubscribeService,接米家/华为/Matter 要重来一遍。 - 休眠设备天然无动态:蓝牙 Mesh 子设备(JD6BTSA / 门磁)休眠不上报,云端与本地都可能感知不到,属设备物理特性。
- 历史数据为空:
device_activity_log长期 0 条(本期已修复涂鸦推送解析 bug:bizCode/bizData在data内)。
2. 设计目标
- 平台无关:动态模型不绑定任何平台,平台差异只体现在"采集适配"层。
- 两类来源统一:主动控制(Command)与被动监听(Observe)归一到同一张表、同一套语义。
- 语义可读:结合物模型把
{"1":true}翻译成"打开 客厅灯"。 - 去重可控:控制与其回执只记一条;短窗口内同设备同功能点合并。
- 渐进对接:先把"自己的"做扎实,再按统一接口接各平台。
3. 总体架构
┌─────────────── 采集层 Collector(三类,平台相关) ───────────────┐
│ ① CommandCollector 我们自己的控制入口 │
│ APP: PlatformServiceManager.controlDevice 成功回调 │
│ 后端: /commands(云端下发)、语音、场景执行 │
│ │
│ ② ObserveCollector APP 本地监听(SDK 直连,最实时) │
│ DP 变化 / 上下线(涂鸦 Mesh、蓝牙、局域网) │
│ │
│ ③ PlatformEventCollector 各平台云端事件(后续对接) │
│ 涂鸦 Pulsar(已有)→ 米家 / 华为 / Matter(后续) │
└──────────────────────────┬───────────────────────────────────────┘
↓
归一化层 Normalizer → DeviceEvent(平台无关)
{ deviceId, platform, type, dpCode, value, ts, raw }
↓
语义层 Enricher(查 product + product_property + ControlSchema)
dpId → code → name / type / enum → "打开 客厅灯"、"亮度调到 60%"
↓
去重层 Dedupe(commandId 配对 + 短窗口合并)
↓
存储 device_activity_log(扩展字段,见第 5 节)
↓
消费:首页近期动态 / 动态详情 / 规则引擎 / 中控屏 / WebSocket 推送
关键:采集层可插拔,下面三层完全平台无关。 新增平台 = 新增一个 Collector/Adapter。
4. 统一事件模型
public class DeviceEvent {
private String deviceId; // 平台设备ID
private String platform; // tuya / mi / huawei / matter / local
private EventType type; // CONTROL / STATE / ONLINE / OFFLINE / ALARM
private String dpCode; // 物模型功能点编码(平台无关语义 key,如 switch_1)
private String dpId; // 平台原始 DP 点(如涂鸦 "1")
private Object value; // 归一化后的值
private Long homeId;
private Long operatorMemberId;// 操作人(主动控制有,被动上报为 null)
private String commandId; // 控制回执配对 ID
private Long ts;
private Object raw; // 原始报文(审计/排查用)
}
EventType:
- CONTROL:我方发起并确认成功的操作(最可信,优先级最高)
- STATE:状态变化(DP 上报/云端推送)
- ONLINE / OFFLINE:上下线
- ALARM:告警类(门磁触发、烟雾报警等,后续)
5. 数据模型演进(V15 迁移草案)
在现有 device_activity_log 上新增列(保留原字段,保证读兼容):
| 新增列 | 类型 | 说明 |
|---|---|---|
platform |
varchar(32) | 平台标识 |
event_type |
varchar(16) | control / state / online / offline / alarm |
dp_code |
varchar(64) | 物模型功能点编码(语义 key) |
value_text |
varchar(128) | 归一化值("true" / "60%" / "cold") |
command_id |
varchar(64) | 控制回执配对,用于去重 |
dedupe_key |
varchar(128) | 去重键,唯一索引 |
raw |
json | 原始平台报文(detail 保留兼容,新数据同时写 raw) |
索引:idx_home_time(home_id, create_time)、idx_device_time(device_id, create_time)、uniq(dedupe_key)
为什么加 dedupe_key 而不是继续用 8 秒查重:查重是"启发式",去重键是"确定性"——由 commandId 或 deviceId + dpCode + 时间桶 生成,重复写入直接被唯一索引拦截,不依赖时序。
6. 关键机制
6.1 控制—回执配对(解决"一次操作记两条")
- 发起控制时生成
commandId(客户端 UUID 或后端生成) - 控制成功 → 写
CONTROL记录(带 commandId) - 随后到达的 STATE(同 commandId,或 同设备+同 dpCode+10s 内)→ 只更新该 CONTROL 记录,不新增
- 超窗仍未配对 → CONTROL 记录保持("指令已下发"),后续 STATE 独立成条
6.2 去重规则
| 场景 | 策略 |
|---|---|
| 控制 + 其回执 | commandId 配对,合并为 1 条 |
| 本地监听 + 云端推送(同一变化) | deviceId + dpCode + 5s 时间桶 去重键 |
| 上下线抖动 | 同设备 60s 内同状态只记 1 条 |
| 场景批量执行 | 按场景维度聚合 1 条("执行场景:回家模式"),不逐设备刷屏 |
去重窗口可配置(iot.device.activity.dedupe.*)。
6.3 语义化(重点,复用 P3 物模型)
映射链:平台 dpId → product_property.code → name / type / enum / unit
原始: device=6c... dps={"1":true}
TSL : dpId "1" → code "switch_1" → name "开关" → type "bool"
输出: "打开 客厅灯"
原始: dps={"22":500}
TSL : dpId "22" → code "bright_value" → name "亮度" → min 0 / max 1000 / unit "%"
输出: "客厅灯 亮度调到 50%"
原始: dps={"1":"cold"}
TSL : dpId "1" → code "mode" → enum ["auto","cold","hot"]
输出: "空调 模式切换为 制冷"
降级策略:产品无物模型时,回退现有 buildDpsDesc()(bool→开/关,其余→"状态更新"),保证老产品不空白。
6.4 平台适配接口(后续对接统一入口)
public interface IPlatformEventAdapter {
DevicePlatform platform();
/** 平台原始报文 → 统一事件 */
List<DeviceEvent> normalize(Object rawEvent);
/** 启动订阅/长连接,事件推给 sink */
void start(Consumer<List<DeviceEvent>> sink);
void stop();
}
- 涂鸦:
TuyaPulsarEventAdapter(把现有TuyaMessageSubscribeService的解析逻辑迁入,含新版bizCode/bizData处理) - 米家 / 华为 / Matter:按同一接口实现,业务层零改动
7. 与现有模块的关系
- 复用 Product / ProductProperty / ControlSchemaService(P3 成果)做语义翻译
- 复用 DeviceEventPublisher(RabbitMQ)把动态分发给规则引擎、中控屏 WebSocket
- 复用 PlatformServiceManager 作为所有控制的统一收口(已在成功回调埋点)
- 中控屏与 APP 共用同一份动态数据(
/api/v1/panel/...命名空间未来直接复用)
8. 分阶段计划
| 阶段 | 内容 | 产出 |
|---|---|---|
| P0 | 表结构扩展(platform/event_type/dp_code/value_text/command_id/dedupe_key/raw)+ 去重键唯一索引 | V15 迁移 |
| P1 | DeviceEvent 模型 + ActivityPipeline(归一化→语义→去重→存储);commandId 贯穿控制链路(APP 与后端) |
一次操作只记一条、语义可读 |
| P2 | 语义层接入物模型(dpId→code→name/enum/unit);无物模型降级 | "亮度调到 50%" 级别描述 |
| P3 | 采集层重构:CommandCollector / ObserveCollector / PlatformEventAdapter;涂鸦 Pulsar 迁入 Adapter |
平台解耦 |
| P4 | 对接米家 / 华为 / Matter;场景聚合、告警类动态、保留策略与清理任务 | 多平台统一动态 |
9. 风险与边界
- 休眠设备天然无动态:蓝牙 Mesh 子设备(JD6BTSA / 门磁)不上报,任何方案都记不到。UI 应对此类设备明示"休眠设备,可能无法记录全部动态",避免用户误解为 bug。
- 去重过激会漏记:窗口需可配置,且保留
raw原始报文便于回溯。 - 数据增长:建议保留策略(如 90 天滚动清理或按月归档),写入全异步不阻塞控制主链路。
- 语义依赖物模型:物模型未录入的产品只能降级输出,需引导在后台补齐(P3 已有录入界面)。
10. 待确认
commandId由客户端生成还是后端下发?(建议客户端 UUID,随控制请求带给后端)- 场景/AI 触发的操作,
operator_member_id记触发人还是留空并记场景 ID? - 动态保留期限与归档策略?
- 是否需要"动态订阅/推送"(设备动态实时推到 APP/中控屏 WebSocket)?