feat(T): 设备动态描述细化到开关通道, 并支持多路控制的一次性配对去重

- APP 侧生成带通道名的描述: 打开 JD2BTSA 开关1 / 全部开关
  (TuyaSdkService.describeSwitchChannels 按 schema 通道名生成, 场景/背光等非电源开关不参与)
- /devices/status 新增 dpsDesc: APP 上报 DP 时带上语义描述, 面板来源的动态同样具体
- 后端 DeviceActivityService 配对增强: 多路设备一次控制多路时, 回执只带其中一路
  也判定为同一次操作(回执 dp 必须是主动控制 dp 的子集且值一致), 避免又出现两条动态
This commit is contained in:
2995840376 2026-10-28 11:54:32 +08:00
parent 15534a3b11
commit 18686dba7c
8 changed files with 202 additions and 23 deletions

View File

@ -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<String, Object> body = new HashMap<>();
body.put("action", action);

View File

@ -79,8 +79,7 @@ public class TuyaSdkService {
}
/** 进程级 DP 上报:常驻生效,页面销毁/切后台也能记录设备动态 */
private static void reportDpToBackend(String devId, Map<String, Object> dps) {
if (devId == null || dps == null || dps.isEmpty()) return;
private static void reportDpToBackend(String devId, Map<String, Object> 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<String, Object> 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<com.xhai.iot.model.Result<Integer>>() {
@ -110,6 +117,42 @@ public class TuyaSdkService {
}
}
/**
* 生成 DP 变化的语义描述(带通道名)。
*
* <p>多路开关设备(两位插座/四位排插)要具体到通道,如「打开 JD2BTSA 开关1」;
* 单路设备就是「打开 JD2BTSA」。</p>
*/
public static String describeDpsAction(String devId, Map<String, Object> 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<String, Object> 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;
}
/**
* 描述本次操作涉及的开关通道(用于动态文案)。
*
* <p>两位插座/四位排插有多路电源开关,动态要具体到「开关1」「开关2」,
* 而不是笼统的「打开 JD2BTSA」。</p>
*
* @return 单路返回通道名(如「开关1」);全部通道返回「全部开关」;
* 无法识别返回 null
*/
public static String describeSwitchChannels(String deviceId, Map<String, Object> commands) {
if (deviceId == null || commands == null || commands.isEmpty()) {
return null;
}
List<SwitchChannel> channels = findSwitchChannels(deviceId);
if (channels.isEmpty()) {
return null;
}
List<String> 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();

View File

@ -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<String, Object> body = new java.util.HashMap<>();
body.put("action", action);
@ -1140,6 +1137,19 @@ public class DeviceControlActivity extends AppCompatActivity {
}
}
/**
* 生成动态描述。
*
* <p>多路开关设备(两位插座/四位排插)要具体到通道,如「打开 JD2BTSA 开关1」。</p>
*/
private String describeAction(String action, String deviceName, Map<String, Object> 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<String, Object> body = new java.util.HashMap<>();
body.put("deviceId", deviceId);
body.put("dps", cmd);
// 带上语义描述(带通道名),后端记录的动态更具体
try {
String action = "adjust";
for (Map.Entry<String, Object> 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<com.xhai.iot.model.Result<Integer>>() {

View File

@ -1222,7 +1222,17 @@ public class HomeFragment extends Fragment {
Map<String, Object> 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<Result<Integer>>() {
@Override

View File

@ -34,6 +34,18 @@ public class ObserveCollector {
*/
public void record(String deviceId, String platform, String source,
String type, Map<String, Object> 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<String, Object> 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);
}
}

View File

@ -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(

View File

@ -25,4 +25,8 @@ public class DeviceStatusSyncRequest {
@Schema(description = "上报来源(用于动态记录): panel=涂鸦面板/物理开关, app=APP主动")
private String source;
@Schema(description = "客户端给出的语义描述(如: 打开 JD2BTSA 开关1)。"
+ "APP 有涂鸦 schema,能给出通道名,比服务端物模型更详细")
private String dpsDesc;
}

View File

@ -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 {
* <p>判据:同设备、同物模型属性、同动作(调节类再比值),且时间窗内已存在
* app/voice/scene 来源的主动控制记录。</p>
*/
private boolean isPairedWithActiveCommand(Device device, Semantic s) {
private boolean isPairedWithActiveCommand(Device device, Semantic s,
Map<String, Object> 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)) {
/**
* 两处记录是否描述了同一个变化。
*
* <p>两种判据:</p>
* <ol>
* <li>精确匹配:属性一致 + 动作一致(调节类再比值);</li>
* <li>集合匹配:多路设备一次控制多路时,回执可能只带其中一路,
* 只要回执涉及的 dp 都在主动控制的 dp 集合内、且值一致,即同一次操作。</li>
* </ol>
*/
private boolean isSameChange(DeviceActivityLog row, Semantic s,
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> parseDetailDps(String detail) {
if (!StringUtils.hasText(detail)) {
return null;
}
try {
return objectMapper.readValue(detail, Map.class);
} catch (Exception e) {
return null;
}
return true;
}
// ==================== 兼容旧接口 ====================