From 18686dba7c83036cc9ddfa1920a81ca0089eb6b1 Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Wed, 28 Oct 2026 11:54:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(T):=20=E8=AE=BE=E5=A4=87=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E7=BB=86=E5=8C=96=E5=88=B0=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E9=80=9A=E9=81=93,=20=E5=B9=B6=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E8=B7=AF=E6=8E=A7=E5=88=B6=E7=9A=84=E4=B8=80=E6=AC=A1=E6=80=A7?= =?UTF-8?q?=E9=85=8D=E5=AF=B9=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - APP 侧生成带通道名的描述: 打开 JD2BTSA 开关1 / 全部开关 (TuyaSdkService.describeSwitchChannels 按 schema 通道名生成, 场景/背光等非电源开关不参与) - /devices/status 新增 dpsDesc: APP 上报 DP 时带上语义描述, 面板来源的动态同样具体 - 后端 DeviceActivityService 配对增强: 多路设备一次控制多路时, 回执只带其中一路 也判定为同一次操作(回执 dp 必须是主动控制 dp 的子集且值一致), 避免又出现两条动态 --- .../iot/service/PlatformServiceManager.java | 9 +- .../com/xhai/iot/service/TuyaSdkService.java | 84 ++++++++++++++++++- .../xhai/iot/ui/DeviceControlActivity.java | 33 +++++++- .../java/com/xhai/iot/ui/HomeFragment.java | 12 ++- .../device/collector/ObserveCollector.java | 13 +++ .../iot/device/controller/TuyaController.java | 4 +- .../dto/tuya/DeviceStatusSyncRequest.java | 4 + .../device/service/DeviceActivityService.java | 66 ++++++++++++--- 8 files changed, 202 insertions(+), 23 deletions(-) diff --git a/Android/app/src/main/java/com/xhai/iot/service/PlatformServiceManager.java b/Android/app/src/main/java/com/xhai/iot/service/PlatformServiceManager.java index 80b9c7a..d792ec9 100644 --- a/Android/app/src/main/java/com/xhai/iot/service/PlatformServiceManager.java +++ b/Android/app/src/main/java/com/xhai/iot/service/PlatformServiceManager.java @@ -166,10 +166,13 @@ public class PlatformServiceManager { } } String name = device.getName() != null ? device.getName() : "设备"; + // 多路开关设备(两位插座/四位排插):动态要具体到通道,如「打开 JD2BTSA 开关1」 + String channel = TuyaSdkService.describeSwitchChannels(device.getDeviceId(), commands); + String suffix = channel != null ? " " + channel : ""; String desc; - if ("switch_on".equals(action)) desc = "打开 " + name; - else if ("switch_off".equals(action)) desc = "关闭 " + name; - else desc = "调节 " + name; + if ("switch_on".equals(action)) desc = "打开 " + name + suffix; + else if ("switch_off".equals(action)) desc = "关闭 " + name + suffix; + else desc = "调节 " + name + suffix; Map body = new HashMap<>(); body.put("action", action); diff --git a/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java b/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java index 1361a1d..8a7ff6a 100644 --- a/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java +++ b/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java @@ -79,8 +79,7 @@ public class TuyaSdkService { } /** 进程级 DP 上报:常驻生效,页面销毁/切后台也能记录设备动态 */ - private static void reportDpToBackend(String devId, Map dps) { - if (devId == null || dps == null || dps.isEmpty()) return; + private static void reportDpToBackend(String devId, Map dps) { if (devId == null || dps == null || dps.isEmpty()) return; try { android.content.Context ctx = com.xhai.iot.TuyaApplication.getAppContext(); if (ctx == null) return; @@ -89,6 +88,14 @@ public class TuyaSdkService { Map body = new HashMap<>(); body.put("deviceId", devId); body.put("dps", dps); + // 带上语义描述(多路开关设备要具体到通道,如「打开 JD2BTSA 开关1」) + try { + String desc = describeDpsAction(devId, dps); + if (desc != null) { + body.put("dpsDesc", desc); + } + } catch (Exception ignored) { + } com.xhai.iot.data.ApiClient.getApiService() .reportDeviceStatus(auth, body) .enqueue(new retrofit2.Callback>() { @@ -110,6 +117,42 @@ public class TuyaSdkService { } } + /** + * 生成 DP 变化的语义描述(带通道名)。 + * + *

多路开关设备(两位插座/四位排插)要具体到通道,如「打开 JD2BTSA 开关1」; + * 单路设备就是「打开 JD2BTSA」。

+ */ + public static String describeDpsAction(String devId, Map dps) { + if (devId == null || dps == null || dps.isEmpty()) { + return null; + } + String deviceName = "设备"; + try { + com.thingclips.smart.sdk.bean.DeviceBean db = + ThingHomeSdk.getDataInstance().getDeviceBean(devId); + if (db != null && db.getName() != null && !db.getName().isEmpty()) { + deviceName = db.getName(); + } + } catch (Exception ignored) { + } + boolean on = false; + boolean found = false; + for (Map.Entry e : dps.entrySet()) { + if (e.getValue() instanceof Boolean) { + on = (Boolean) e.getValue(); + found = true; + break; + } + } + String channel = describeSwitchChannels(devId, dps); + String suffix = channel != null ? " " + channel : ""; + if (!found) { + return deviceName + " 状态更新"; + } + return (on ? "打开 " : "关闭 ") + deviceName + suffix; + } + private static void notifyStatusChanged(String devId, boolean online) { for (DeviceStatusCallback cb : statusCallbacks) { try { cb.onStatusChanged(devId, online); } catch (Exception ignored) {} @@ -933,6 +976,43 @@ public class TuyaSdkService { return null; } + /** + * 描述本次操作涉及的开关通道(用于动态文案)。 + * + *

两位插座/四位排插有多路电源开关,动态要具体到「开关1」「开关2」, + * 而不是笼统的「打开 JD2BTSA」。

+ * + * @return 单路返回通道名(如「开关1」);全部通道返回「全部开关」; + * 无法识别返回 null + */ + public static String describeSwitchChannels(String deviceId, Map commands) { + if (deviceId == null || commands == null || commands.isEmpty()) { + return null; + } + List channels = findSwitchChannels(deviceId); + if (channels.isEmpty()) { + return null; + } + List names = new ArrayList<>(); + for (SwitchChannel ch : channels) { + boolean hit = commands.containsKey(ch.dpId) + || (ch.code != null && commands.containsKey(ch.code)); + if (hit && ch.name != null && !names.contains(ch.name)) { + names.add(ch.name); + } + } + if (names.isEmpty()) { + return null; + } + if (names.size() == 1) { + return names.get(0); + } + if (names.size() == channels.size()) { + return "全部开关"; + } + return String.join("、", names); + } + /** 清除全部开关 DP 缓存(设备重新配网/schema 变化后调用) */ public static void clearSwitchDpCache() { SWITCH_DP_CACHE.clear(); diff --git a/Android/app/src/main/java/com/xhai/iot/ui/DeviceControlActivity.java b/Android/app/src/main/java/com/xhai/iot/ui/DeviceControlActivity.java index 4745aed..b19c1bd 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/DeviceControlActivity.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/DeviceControlActivity.java @@ -1109,10 +1109,7 @@ public class DeviceControlActivity extends AppCompatActivity { break; } } - String desc; - if ("switch_on".equals(action)) desc = "打开 " + name; - else if ("switch_off".equals(action)) desc = "关闭 " + name; - else desc = "调节 " + name; + String desc = describeAction(action, name, cmd); java.util.Map body = new java.util.HashMap<>(); body.put("action", action); @@ -1140,6 +1137,19 @@ public class DeviceControlActivity extends AppCompatActivity { } } + /** + * 生成动态描述。 + * + *

多路开关设备(两位插座/四位排插)要具体到通道,如「打开 JD2BTSA 开关1」。

+ */ + private String describeAction(String action, String deviceName, Map cmd) { + String channel = TuyaSdkService.describeSwitchChannels(deviceId, cmd); + String suffix = channel != null ? " " + channel : ""; + if ("switch_on".equals(action)) return "打开 " + deviceName + suffix; + if ("switch_off".equals(action)) return "关闭 " + deviceName + suffix; + return "调节 " + deviceName + suffix; + } + /** * 上报控制后的最新 DP 值到后端。 * @@ -1154,6 +1164,21 @@ public class DeviceControlActivity extends AppCompatActivity { java.util.Map body = new java.util.HashMap<>(); body.put("deviceId", deviceId); body.put("dps", cmd); + // 带上语义描述(带通道名),后端记录的动态更具体 + try { + String action = "adjust"; + for (Map.Entry en : cmd.entrySet()) { + if (en.getValue() instanceof Boolean) { + action = ((Boolean) en.getValue()) ? "switch_on" : "switch_off"; + break; + } + } + String deviceName = "设备"; + DeviceBean db = ThingHomeSdk.getDataInstance().getDeviceBean(deviceId); + if (db != null && db.getName() != null) deviceName = db.getName(); + body.put("dpsDesc", describeAction(action, deviceName, cmd)); + } catch (Exception ignored) { + } com.xhai.iot.data.ApiClient.getApiService() .reportDeviceStatus(auth, body) .enqueue(new retrofit2.Callback>() { diff --git a/Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java b/Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java index 673550d..ab9240c 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java @@ -1222,7 +1222,17 @@ public class HomeFragment extends Fragment { Map body = new HashMap<>(); body.put("deviceId", deviceId); if (online != null) body.put("online", online); - if (dps != null && !dps.isEmpty()) body.put("dps", dps); + if (dps != null && !dps.isEmpty()) { + body.put("dps", dps); + // 带上语义描述(多路开关具体到通道,如「打开 JD2BTSA 开关1」) + try { + String desc = TuyaSdkService.describeDpsAction(deviceId, dps); + if (desc != null) { + body.put("dpsDesc", desc); + } + } catch (Exception ignored) { + } + } ApiClient.getApiService().reportDeviceStatus(auth, body) .enqueue(new retrofit2.Callback>() { @Override diff --git a/backend/iot-device/src/main/java/com/iot/device/collector/ObserveCollector.java b/backend/iot-device/src/main/java/com/iot/device/collector/ObserveCollector.java index 7ae0e9b..80554b6 100644 --- a/backend/iot-device/src/main/java/com/iot/device/collector/ObserveCollector.java +++ b/backend/iot-device/src/main/java/com/iot/device/collector/ObserveCollector.java @@ -34,6 +34,18 @@ public class ObserveCollector { */ public void record(String deviceId, String platform, String source, String type, Map dps, Object raw) { + record(deviceId, platform, source, type, dps, raw, null); + } + + /** + * 记录一条被动状态变化动态(带语义描述)。 + * + * @param actionDesc 客户端给出的语义描述(如「打开 JD2BTSA 开关1」); + * 为空时由服务端语义化推导 + */ + public void record(String deviceId, String platform, String source, + String type, Map dps, Object raw, + String actionDesc) { DeviceEvent event = new DeviceEvent(); event.setDeviceId(deviceId); event.setPlatform(platform); @@ -41,6 +53,7 @@ public class ObserveCollector { event.setType(type); event.setDps(dps); event.setRaw(raw); + event.setActionDesc(actionDesc); activityService.recordEvent(event); } } diff --git a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java index 2c28914..572fe57 100644 --- a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java +++ b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java @@ -559,7 +559,9 @@ public class TuyaController { // 经统一管线:语义化(物模型) + 确定性去重。单纯上下线上报不记,云端推送已覆盖。 observeCollector.record(req.getDeviceId(), DevicePlatform.TUYA.code(), StringUtils.hasText(req.getSource()) ? req.getSource() : "panel", - "state", req.getDps(), req.getDps()); + "state", req.getDps(), req.getDps(), + // 客户端(APP)能给出带通道名的描述,如「打开 JD2BTSA 开关1」 + StringUtils.hasText(req.getDpsDesc()) ? req.getDpsDesc() : null); } // 发布设备数据变化事件,触发规则引擎场景联动 deviceEventPublisher.publishDeviceDataChange( diff --git a/backend/iot-device/src/main/java/com/iot/device/dto/tuya/DeviceStatusSyncRequest.java b/backend/iot-device/src/main/java/com/iot/device/dto/tuya/DeviceStatusSyncRequest.java index af8a7d1..94a6e3d 100644 --- a/backend/iot-device/src/main/java/com/iot/device/dto/tuya/DeviceStatusSyncRequest.java +++ b/backend/iot-device/src/main/java/com/iot/device/dto/tuya/DeviceStatusSyncRequest.java @@ -25,4 +25,8 @@ public class DeviceStatusSyncRequest { @Schema(description = "上报来源(用于动态记录): panel=涂鸦面板/物理开关, app=APP主动") private String source; + + @Schema(description = "客户端给出的语义描述(如: 打开 JD2BTSA 开关1)。" + + "APP 有涂鸦 schema,能给出通道名,比服务端物模型更详细") + private String dpsDesc; } \ No newline at end of file diff --git a/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java b/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java index 284148f..4312d4c 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java @@ -115,7 +115,7 @@ public class DeviceActivityService { // APP 主动控制时先落一条 source=app 的动态,随后设备把同一变化回传上来 // (source=panel)。二者是同一次操作,用户只应看到一条「APP 操作」。 // 这里按「同设备 + 同物模型属性 + 同动作 + 时间窗内已有主动控制」判定为回执并跳过。 - if (isPassiveFeedback(event, type) && isPairedWithActiveCommand(device, s)) { + if (isPassiveFeedback(event, type) && isPairedWithActiveCommand(device, s, event.getDps())) { log.debug("回执已与主动控制配对,跳过重复动态: deviceId={} dp={} action={}", device.getDeviceId(), s.dpCode, s.action); return; @@ -306,7 +306,8 @@ public class DeviceActivityService { *

判据:同设备、同物模型属性、同动作(调节类再比值),且时间窗内已存在 * app/voice/scene 来源的主动控制记录。

*/ - private boolean isPairedWithActiveCommand(Device device, Semantic s) { + private boolean isPairedWithActiveCommand(Device device, Semantic s, + Map currentDps) { // 回执与 APP 主动上报几乎同时到达,稍等一下再判定,避免"先到先判"漏配对 try { Thread.sleep(FEEDBACK_PAIR_DELAY_MS); @@ -324,7 +325,7 @@ public class DeviceActivityService { .orderByDesc(DeviceActivityLog::getCreateTime) .last("LIMIT 10")); for (DeviceActivityLog row : list) { - if (isSameChange(row, s)) { + if (isSameChange(row, s, currentDps)) { return true; } } @@ -334,19 +335,60 @@ public class DeviceActivityService { return false; } - /** 两处记录是否描述了同一个变化(属性一致 + 动作一致) */ - private boolean isSameChange(DeviceActivityLog row, Semantic s) { - if (!java.util.Objects.equals(row.getDpCode(), s.dpCode)) { + /** + * 两处记录是否描述了同一个变化。 + * + *

两种判据:

+ *
    + *
  1. 精确匹配:属性一致 + 动作一致(调节类再比值);
  2. + *
  3. 集合匹配:多路设备一次控制多路时,回执可能只带其中一路, + * 只要回执涉及的 dp 都在主动控制的 dp 集合内、且值一致,即同一次操作。
  4. + *
+ */ + private boolean isSameChange(DeviceActivityLog row, Semantic s, + Map currentDps) { + if (java.util.Objects.equals(row.getDpCode(), s.dpCode) + && java.util.Objects.equals(row.getAction(), s.action)) { + if (!"adjust".equals(s.action)) { + return true; + } + // 调节类动作名相同但值可能不同(如 调到 30% vs 调到 60%) + if (java.util.Objects.equals(row.getValueText(), s.valueText)) { + return true; + } + } + // 集合匹配:回执的 dp 必须是主动控制 dp 的子集,且值一致 + Map rowDps = parseDetailDps(row.getDetail()); + if (rowDps == null || currentDps == null || currentDps.isEmpty()) { return false; } - if (!java.util.Objects.equals(row.getAction(), s.action)) { - return false; + boolean any = false; + for (Map.Entry e : currentDps.entrySet()) { + if (!rowDps.containsKey(e.getKey())) { + // 回执涉及的 dp 不在主动控制里 → 是另一次操作(如面板全开 vs APP 只开一路) + return false; + } + Object v = rowDps.get(e.getKey()); + if (!java.util.Objects.equals(String.valueOf(v), String.valueOf(e.getValue()))) { + // 同一 dp 值不同 → 是另一次操作 + return false; + } + any = true; } - if ("adjust".equals(s.action)) { - // 调节类动作动作名相同但值可能不同(如 调到 30% vs 调到 60%) - return java.util.Objects.equals(row.getValueText(), s.valueText); + return any; + } + + /** 解析明细 JSON 为 dp map(失败返回 null) */ + @SuppressWarnings("unchecked") + private Map parseDetailDps(String detail) { + if (!StringUtils.hasText(detail)) { + return null; + } + try { + return objectMapper.readValue(detail, Map.class); + } catch (Exception e) { + return null; } - return true; } // ==================== 兼容旧接口 ====================