feat(T): 设备动态描述细化到开关通道, 并支持多路控制的一次性配对去重
- APP 侧生成带通道名的描述: 打开 JD2BTSA 开关1 / 全部开关 (TuyaSdkService.describeSwitchChannels 按 schema 通道名生成, 场景/背光等非电源开关不参与) - /devices/status 新增 dpsDesc: APP 上报 DP 时带上语义描述, 面板来源的动态同样具体 - 后端 DeviceActivityService 配对增强: 多路设备一次控制多路时, 回执只带其中一路 也判定为同一次操作(回执 dp 必须是主动控制 dp 的子集且值一致), 避免又出现两条动态
This commit is contained in:
parent
15534a3b11
commit
18686dba7c
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>>() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -25,4 +25,8 @@ public class DeviceStatusSyncRequest {
|
||||
|
||||
@Schema(description = "上报来源(用于动态记录): panel=涂鸦面板/物理开关, app=APP主动")
|
||||
private String source;
|
||||
|
||||
@Schema(description = "客户端给出的语义描述(如: 打开 JD2BTSA 开关1)。"
|
||||
+ "APP 有涂鸦 schema,能给出通道名,比服务端物模型更详细")
|
||||
private String dpsDesc;
|
||||
}
|
||||
@ -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,20 +335,61 @@ public class DeviceActivityService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 两处记录是否描述了同一个变化(属性一致 + 动作一致) */
|
||||
private boolean isSameChange(DeviceActivityLog row, Semantic s) {
|
||||
if (!java.util.Objects.equals(row.getDpCode(), s.dpCode)) {
|
||||
return false;
|
||||
}
|
||||
if (!java.util.Objects.equals(row.getAction(), s.action)) {
|
||||
return false;
|
||||
}
|
||||
if ("adjust".equals(s.action)) {
|
||||
// 调节类动作动作名相同但值可能不同(如 调到 30% vs 调到 60%)
|
||||
return java.util.Objects.equals(row.getValueText(), s.valueText);
|
||||
}
|
||||
/**
|
||||
* 两处记录是否描述了同一个变化。
|
||||
*
|
||||
* <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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 兼容旧接口 ====================
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user