diff --git a/Android/app/src/main/java/com/xhai/iot/model/BackendDevice.java b/Android/app/src/main/java/com/xhai/iot/model/BackendDevice.java
index 6125333..58158fd 100644
--- a/Android/app/src/main/java/com/xhai/iot/model/BackendDevice.java
+++ b/Android/app/src/main/java/com/xhai/iot/model/BackendDevice.java
@@ -2,6 +2,8 @@ package com.xhai.iot.model;
import com.google.gson.annotations.SerializedName;
+import java.util.Map;
+
/**
* 本平台后端设备(device 表),首页设备列表数据源。
* 家庭/房间/设备清单由我们后端管理,涂鸦 SDK 仅用于设备控制。
@@ -35,6 +37,30 @@ public class BackendDevice {
@SerializedName("isGateway")
private Integer isGateway;
+ /** 设备能力描述(JSON),其中 dps 为后端掌握的最新 DP 值 */
+ @SerializedName("capabilities")
+ private Map capabilities;
+
+ public Map getCapabilities() { return capabilities; }
+ public void setCapabilities(Map capabilities) { this.capabilities = capabilities; }
+
+ /**
+ * 后端掌握的最新 DP 值。
+ *
+ * 后端是多来源的汇聚点:APP 控制回传、面板/中控屏上报、涂鸦云消息订阅都会写这里。
+ * 涂鸦 SDK 数据层在「Mesh 本地通道控制」「子设备无云端推送」等场景下不会更新,
+ * 所以卡片状态必须以后端这份数据为准。
+ */
+ @SuppressWarnings("unchecked")
+ public Map getBackendDps() {
+ if (capabilities == null) return null;
+ Object dps = capabilities.get("dps");
+ if (dps instanceof Map) {
+ return (Map) dps;
+ }
+ return null;
+ }
+
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
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 fbdd0b9..80b9c7a 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
@@ -104,6 +104,9 @@ public class PlatformServiceManager {
@Override
public void onSuccess() {
reportActivity(device, commands);
+ // 同步最新 DP 值到后端并通知首页刷新卡片状态
+ // (Mesh 本地通道控制时涂鸦 SDK 数据层不更新,首页会显示旧状态)
+ reportDps(device, commands);
if (callback != null) callback.onSuccess();
}
@@ -114,6 +117,40 @@ public class PlatformServiceManager {
});
}
+ /**
+ * 上报控制后的最新 DP 值到后端(供首页/设备页显示开关状态)
+ *
+ * 后端是设备状态的汇聚点:APP 控制回传、面板·中控屏上报、涂鸦云消息订阅
+ * 都写进同一处,卡片状态以后端这份数据为准。
+ */
+ private void reportDps(PlatformDevice device, Map commands) {
+ try {
+ Context ctx = context;
+ if (ctx == null || device == null || commands == null || commands.isEmpty()) return;
+ String auth = SessionManager.getInstance(ctx).getBearerToken();
+ if (auth == null) return;
+ Map body = new HashMap<>();
+ body.put("deviceId", device.getDeviceId());
+ body.put("dps", commands);
+ ApiClient.getApiService()
+ .reportDeviceStatus(auth, body)
+ .enqueue(new retrofit2.Callback>() {
+ @Override
+ public void onResponse(retrofit2.Call> call,
+ retrofit2.Response> response) {
+ com.xhai.iot.util.ActivityEventBus.notifyStateChanged(device.getDeviceId());
+ }
+
+ @Override
+ public void onFailure(retrofit2.Call> call, Throwable t) {
+ Log.w(TAG, "上报DP状态失败: " + t.getMessage());
+ }
+ });
+ } catch (Exception e) {
+ Log.w(TAG, "上报DP状态异常: " + e.getMessage());
+ }
+ }
+
/** 上报控制动态到后端(失败静默,不影响控制) */
private void reportActivity(PlatformDevice device, Map commands) {
try {
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 35c8480..4745aed 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
@@ -1071,6 +1071,9 @@ public class DeviceControlActivity extends AppCompatActivity {
public void onSuccess() {
// APP 走 SDK 直连不经后端,需主动上报,否则首页「控制记录」永远为空
reportControlActivity(cmd);
+ // 面板操作后把最新 DP 值同步到后端并通知首页刷新卡片状态
+ // (Mesh 本地通道控制时涂鸦 SDK 数据层不会更新,只靠 SDK 会一直显示旧状态)
+ reportDpsToBackend(cmd);
runOnUiThread(() -> Toast.makeText(DeviceControlActivity.this,
"指令已下发", Toast.LENGTH_SHORT).show());
}
@@ -1137,6 +1140,42 @@ public class DeviceControlActivity extends AppCompatActivity {
}
}
+ /**
+ * 上报控制后的最新 DP 值到后端。
+ *
+ * 后端是设备状态的汇聚点(APP/面板/中控屏/涂鸦云订阅都写这里),首页卡片
+ * 以后端这份数据为准。Mesh 本地通道控制时涂鸦 SDK 数据层不会更新,若不上报,
+ * 首页会一直显示控制前的旧状态。
+ */
+ private void reportDpsToBackend(Map cmd) {
+ try {
+ String auth = com.xhai.iot.util.SessionManager.getInstance(this).getBearerToken();
+ if (auth == null || cmd == null || cmd.isEmpty()) return;
+ java.util.Map body = new java.util.HashMap<>();
+ body.put("deviceId", deviceId);
+ body.put("dps", cmd);
+ com.xhai.iot.data.ApiClient.getApiService()
+ .reportDeviceStatus(auth, body)
+ .enqueue(new retrofit2.Callback>() {
+ @Override
+ public void onResponse(retrofit2.Call> call,
+ retrofit2.Response> response) {
+ // 通知首页:设备状态已变化,刷新卡片上的开关状态
+ com.xhai.iot.util.ActivityEventBus.notifyStateChanged(deviceId);
+ }
+
+ @Override
+ public void onFailure(retrofit2.Call> call,
+ Throwable t) {
+ android.util.Log.w("DeviceControlActivity",
+ "上报DP状态失败: " + t.getMessage());
+ }
+ });
+ } catch (Exception e) {
+ android.util.Log.w("DeviceControlActivity", "上报DP状态异常: " + e.getMessage());
+ }
+ }
+
/** 蓝牙 Mesh 子设备控制(走 Mesh 本地通道) */
private void sendSigMeshCommand(DeviceBean dev, Map cmd, String json) {
try {
@@ -1155,6 +1194,8 @@ public class DeviceControlActivity extends AppCompatActivity {
public void onSuccess() {
// APP 走 SDK 直连不经后端,需主动上报,否则首页「控制记录」永远为空
reportControlActivity(cmd);
+ // 面板操作后把最新 DP 值同步到后端并通知首页刷新卡片状态
+ reportDpsToBackend(codeCmd);
runOnUiThread(() -> Toast.makeText(DeviceControlActivity.this,
"指令已下发", Toast.LENGTH_SHORT).show());
}
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 8831c9a..4591bd4 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
@@ -96,6 +96,8 @@ public class HomeFragment extends Fragment {
TuyaSdkService.registerStatusCallback(deviceStatusCallback);
// 注册动态事件回调:任意控制落库成功后,首页「近期动态」立即刷新
ActivityEventBus.register(activityEventListener);
+ // 注册设备状态回调:面板/中控屏操作后刷新卡片上的开关状态
+ ActivityEventBus.registerStateListener(deviceStateListener);
}
@Override
@@ -104,6 +106,7 @@ public class HomeFragment extends Fragment {
unregisterAllTuyaListeners();
TuyaSdkService.unregisterStatusCallback(deviceStatusCallback);
ActivityEventBus.unregister(activityEventListener);
+ ActivityEventBus.unregisterStateListener(deviceStateListener);
if (activityRefreshTask != null) {
activityRefreshHandler.removeCallbacks(activityRefreshTask);
activityRefreshTask = null;
@@ -185,10 +188,11 @@ public class HomeFragment extends Fragment {
*
* 用途:面板/物理开关的操作,只有在手机能收到设备 DP 回流时才会被感知
* (此时事件总线会立即通知,不必等心跳);但当操作发生在手机不在家/无蓝牙连接时,
- * 只有后端能感知(涂鸦云消息订阅打通后)。心跳作为兜底,让首页在停留期间
- * 也能把后端侧新产生的动态拉回来。
+ * 只有后端能感知。心跳作为兜底,让首页在停留期间也能把后端侧新产生的动态、
+ * 以及其它入口(面板/中控屏)改动的设备状态拉回来。
*
- * 只刷新动态列表,不触碰设备列表,因此不会造成常用设备跳位。
+ * 设备列表走轻量刷新(不做探活/重注册监听),且顺序固定、值没变不重绑卡片,
+ * 因此不会造成常用设备跳位或闪烁。
*/
private static final long ACTIVITY_HEARTBEAT_MS = 5_000L;
private final android.os.Handler heartbeatHandler =
@@ -197,7 +201,10 @@ public class HomeFragment extends Fragment {
@Override
public void run() {
if (isAdded() && getView() != null) {
+ // 动态:后端侧产生的操控记录
loadRecentActivities();
+ // 设备状态:面板/中控屏等其它入口的操作(手机收不到 DP 回传时也能感知)
+ loadDevicesFromBackend(false);
}
heartbeatHandler.postDelayed(this, ACTIVITY_HEARTBEAT_MS);
}
@@ -275,6 +282,15 @@ public class HomeFragment extends Fragment {
}
};
+ /** 设备状态变化回调:面板/中控屏操作后刷新卡片开关状态 */
+ private final ActivityEventBus.StateListener deviceStateListener = deviceId -> {
+ if (!isAdded()) return;
+ android.util.Log.i("HomeFragment", "设备状态变化,刷新卡片: deviceId=" + deviceId);
+ if (rvDevices != null) {
+ rvDevices.post(HomeFragment.this::scheduleDeviceRefresh);
+ }
+ };
+
// ==================== 控制后延迟对齐(避免常用设备整表闪动) ====================
/** 用户刚点击控制的设备ID */
@@ -559,6 +575,16 @@ public class HomeFragment extends Fragment {
/** 从本平台后端拉取设备列表(按当前家庭过滤) */
private void loadDevicesFromBackend() {
+ loadDevicesFromBackend(true);
+ }
+
+ /**
+ * 拉取设备列表
+ *
+ * @param full true = 完整刷新(探活、监听注册、室内温度等副作用);
+ * false = 轻量刷新(只更新卡片状态,用于心跳等高频场景)
+ */
+ private void loadDevicesFromBackend(boolean full) {
// 已成功加载过就静默刷新,避免每次 DP 同步都闪一下加载圈
showLoading(!devicesLoadedOnce);
String auth = session.getBearerToken();
@@ -590,10 +616,14 @@ public class HomeFragment extends Fragment {
updateQuickStats();
return;
}
- allDevices = toTuyaDevices(list);
+ allDevices = toTuyaDevices(list, full);
tvEmpty.setVisibility(View.GONE);
adapter.update(allDevices);
updateQuickStats();
+ if (!full) {
+ // 轻量刷新:只更新卡片状态,不做探活/监听注册等副作用
+ return;
+ }
// 从涂鸦设备 DP 读取室内温湿度(温湿度传感器/空调)
loadIndoorTemp();
// 同步涂鸦默认家庭 gid 给 BizBundle(原生面板依赖 gid)
@@ -1206,7 +1236,7 @@ public class HomeFragment extends Fragment {
}
/** 后端设备 → TuyaDevice(复用现有卡片 UI) */
- private List toTuyaDevices(List list) {
+ private List toTuyaDevices(List list, boolean allowCorrection) {
sdkDeviceDataMissing = false;
List result = new ArrayList<>();
for (BackendDevice bd : list) {
@@ -1235,14 +1265,14 @@ public class HomeFragment extends Fragment {
bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId());
td.setOnline(sdkOnline);
// 与后端不一致时回写纠正,保证下次进入/其他页面也准确
- if (sdkOnline != bd.isOnline()) {
+ if (sdkOnline != bd.isOnline() && allowCorrection) {
android.util.Log.i("HomeFragment", "在线状态纠正: " + bd.getDeviceName()
+ " 后端=" + bd.getStatus() + " SDK=" + sdkOnline);
reportStatusToBackend(bd.getDeviceId(), sdkOnline, null);
}
if (devBean != null) {
if (devBean.getDps() != null) {
- td.setDps(devBean.getDps());
+ td.setDps(new HashMap<>(devBean.getDps()));
}
if (devBean.getCategory() != null && !devBean.getCategory().isEmpty()) {
td.setCategory(devBean.getCategory());
@@ -1259,6 +1289,11 @@ public class HomeFragment extends Fragment {
android.util.Log.w("HomeFragment", "SDK状态读取异常 " + bd.getDeviceName(), e);
}
result.add(td);
+ // DP 值合并:以后端为准。
+ // 后端是多来源汇聚点(APP 控制回传 / 面板·中控屏上报 / 涂鸦云消息订阅都会写入),
+ // 而涂鸦 SDK 数据层在「Mesh 本地通道控制」「子设备无云端推送」时不会更新,
+ // 只用 SDK 的 dps 会出现「面板已打开,首页仍显示已关闭」。
+ mergeBackendDps(bd, td);
}
// 1) 先按「上一次显示的顺序」重排:设备位置对用户是固定的,
// 不能因为一次控制上报就让常用设备换位置(JD2BTSA 跳到 JD6BTSA 前面)。
@@ -1272,6 +1307,45 @@ public class HomeFragment extends Fragment {
/** 上一次渲染的设备顺序(设备ID),用于保持列表位置稳定 */
private final List lastDeviceOrder = new ArrayList<>();
+ /**
+ * 合并后端 DP 值到设备状态(后端优先)。
+ *
+ * 后端 DP 的键可能是涂鸦 dpId(如 "2"),也可能是物模型 code(如 "switch_2")
+ * (Mesh 本地通道上报的是 code),这里统一归一化成 dpId,与 SDK 数据层对齐。
+ */
+ private void mergeBackendDps(BackendDevice bd, TuyaDevice td) {
+ Map backendDps = bd.getBackendDps();
+ if (backendDps == null || backendDps.isEmpty()) {
+ return;
+ }
+ if (td.getDps() == null) {
+ td.setDps(new HashMap<>());
+ }
+ for (Map.Entry e : backendDps.entrySet()) {
+ String key = normalizeDpKey(bd.getDeviceId(), e.getKey());
+ if (key == null) continue;
+ td.getDps().put(key, e.getValue());
+ }
+ }
+
+ /** DP 键归一化:物模型 code → 涂鸦 dpId;已是 dpId 则原样返回 */
+ private String normalizeDpKey(String deviceId, String key) {
+ if (key == null || key.isEmpty()) return null;
+ if (key.matches("\\d+")) return key;
+ try {
+ Map idToCode = TuyaSdkService.getDpCodeMap(deviceId);
+ if (idToCode != null) {
+ for (Map.Entry e : idToCode.entrySet()) {
+ if (key.equals(e.getValue())) {
+ return e.getKey();
+ }
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ return key;
+ }
+
/**
* 按上次顺序重排设备列表。
*
diff --git a/Android/app/src/main/java/com/xhai/iot/util/ActivityEventBus.java b/Android/app/src/main/java/com/xhai/iot/util/ActivityEventBus.java
index 0faf478..6dd87c8 100644
--- a/Android/app/src/main/java/com/xhai/iot/util/ActivityEventBus.java
+++ b/Android/app/src/main/java/com/xhai/iot/util/ActivityEventBus.java
@@ -66,4 +66,51 @@ public final class ActivityEventBus {
}
});
}
+
+ // ==================== 设备状态变化事件 ====================
+
+ /**
+ * 设备状态变化监听
+ *
+ * 面板/中控屏操作或本地控制成功后触发,页面据此刷新设备卡片状态
+ * (开关是开是关)。
+ */
+ public interface StateListener {
+ void onDeviceStateChanged(String deviceId);
+ }
+
+ private static final List stateListeners = new ArrayList<>();
+
+ public static void registerStateListener(StateListener listener) {
+ if (listener == null) return;
+ synchronized (stateListeners) {
+ if (!stateListeners.contains(listener)) {
+ stateListeners.add(listener);
+ }
+ }
+ }
+
+ public static void unregisterStateListener(StateListener listener) {
+ if (listener == null) return;
+ synchronized (stateListeners) {
+ stateListeners.remove(listener);
+ }
+ }
+
+ /** 通知所有监听:设备状态已变化,刷新卡片状态 */
+ public static void notifyStateChanged(String deviceId) {
+ final List snapshot;
+ synchronized (stateListeners) {
+ if (stateListeners.isEmpty()) return;
+ snapshot = new ArrayList<>(stateListeners);
+ }
+ MAIN.post(() -> {
+ for (StateListener l : snapshot) {
+ try {
+ l.onDeviceStateChanged(deviceId);
+ } catch (Exception ignored) {
+ }
+ }
+ });
+ }
}
diff --git a/backend/iot-device/src/main/java/com/iot/device/service/PanelStateService.java b/backend/iot-device/src/main/java/com/iot/device/service/PanelStateService.java
index 0632726..6dd0f7a 100644
--- a/backend/iot-device/src/main/java/com/iot/device/service/PanelStateService.java
+++ b/backend/iot-device/src/main/java/com/iot/device/service/PanelStateService.java
@@ -113,6 +113,9 @@ public class PanelStateService {
} else {
deviceStateMapper.updateById(state);
}
+ // 同步到设备能力(dps):中控屏/面板改动的设备状态要让 APP 卡片读到,
+ // 否则 APP 只能看到涂鸦 SDK 里的旧值(面板已打开、首页仍显示已关闭)。
+ syncCapabilitiesDps(deviceId, propertyCode, value);
}
PanelReportDto.PropertyResult result = new PanelReportDto.PropertyResult();
@@ -126,6 +129,43 @@ public class PanelStateService {
return result;
}
+ /**
+ * 把属性状态同步到 device.capabilities.dps。
+ *
+ * device.capabilities 是 APP 读取设备最新 DP 的入口(APP 控制回传、涂鸦云
+ * 消息订阅也写这里)。中控屏上报的属性(物模型 code)同样写进去,APP 端会把
+ * code 归一化成涂鸦 dpId 后与 SDK 数据合并,保证卡片开关状态正确。
+ */
+ private void syncCapabilitiesDps(String deviceId, String propertyCode, Object value) {
+ if (deviceId == null || propertyCode == null || propertyCode.isEmpty()) {
+ return;
+ }
+ try {
+ Device device = deviceMapper.selectByDeviceId(deviceId);
+ if (device == null) {
+ return;
+ }
+ com.fasterxml.jackson.databind.ObjectMapper mapper =
+ new com.fasterxml.jackson.databind.ObjectMapper();
+ Map caps = new java.util.LinkedHashMap<>();
+ com.fasterxml.jackson.databind.JsonNode capsNode = device.getCapabilities();
+ if (capsNode != null && capsNode.isObject()) {
+ caps.putAll(mapper.convertValue(capsNode, Map.class));
+ }
+ @SuppressWarnings("unchecked")
+ Map dps = (caps.get("dps") instanceof Map)
+ ? (Map) caps.get("dps") : new java.util.LinkedHashMap<>();
+ dps.put(propertyCode, value);
+ caps.put("dps", dps);
+ Device update = new Device();
+ update.setId(device.getId());
+ update.setCapabilities(mapper.valueToTree(caps));
+ deviceMapper.updateById(update);
+ } catch (Exception e) {
+ log.warn("同步设备能力失败 deviceId={} property={}: {}", deviceId, propertyCode, e.getMessage());
+ }
+ }
+
/**
* 比较状态新旧:世代更大者更新;同世代下序号更大者更新。
*/