fix(R): 设备卡片开关状态改用后端最新DP, 修复面板操作后首页仍显示已关闭

- 根因: 卡片状态只读涂鸦 SDK 数据层的 dps, 而 Mesh 本地通道控制/子设备无云端推送时
  SDK 不更新, 面板已打开首页仍显示已关闭
- 后端是状态汇聚点(APP回传/中控屏上报/涂鸦云订阅), 设备列表返回 capabilities.dps,
  APP 合并后端DP(物模型code归一化为dpId)覆盖SDK值
- APP 面板与首页开关控制成功后上报最新DP到后端并通知首页刷新卡片
- 中控屏上报的属性状态同步写入 device.capabilities.dps
- 首页心跳(5s)同时轻量刷新设备列表, 感知其它入口(面板/中控屏)的设备状态变化
This commit is contained in:
2995840376 2026-10-28 11:34:27 +08:00
parent 197f97ec1d
commit a894013de7
6 changed files with 272 additions and 7 deletions

View File

@ -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<String, Object> capabilities;
public Map<String, Object> getCapabilities() { return capabilities; }
public void setCapabilities(Map<String, Object> capabilities) { this.capabilities = capabilities; }
/**
* 后端掌握的最新 DP 值。
*
* <p>后端是多来源的汇聚点:APP 控制回传、面板/中控屏上报、涂鸦云消息订阅都会写这里。
* 涂鸦 SDK 数据层在「Mesh 本地通道控制」「子设备无云端推送」等场景下不会更新,
* 所以卡片状态必须以后端这份数据为准。</p>
*/
@SuppressWarnings("unchecked")
public Map<String, Object> getBackendDps() {
if (capabilities == null) return null;
Object dps = capabilities.get("dps");
if (dps instanceof Map) {
return (Map<String, Object>) dps;
}
return null;
}
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }

View File

@ -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 值到后端(供首页/设备页显示开关状态)
*
* <p>后端是设备状态的汇聚点:APP 控制回传、面板·中控屏上报、涂鸦云消息订阅
* 都写进同一处,卡片状态以后端这份数据为准。</p>
*/
private void reportDps(PlatformDevice device, Map<String, Object> 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<String, Object> body = new HashMap<>();
body.put("deviceId", device.getDeviceId());
body.put("dps", commands);
ApiClient.getApiService()
.reportDeviceStatus(auth, body)
.enqueue(new retrofit2.Callback<Result<Integer>>() {
@Override
public void onResponse(retrofit2.Call<Result<Integer>> call,
retrofit2.Response<Result<Integer>> response) {
com.xhai.iot.util.ActivityEventBus.notifyStateChanged(device.getDeviceId());
}
@Override
public void onFailure(retrofit2.Call<Result<Integer>> call, Throwable t) {
Log.w(TAG, "上报DP状态失败: " + t.getMessage());
}
});
} catch (Exception e) {
Log.w(TAG, "上报DP状态异常: " + e.getMessage());
}
}
/** 上报控制动态到后端(失败静默,不影响控制) */
private void reportActivity(PlatformDevice device, Map<String, Object> commands) {
try {

View File

@ -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 值到后端。
*
* <p>后端是设备状态的汇聚点(APP/面板/中控屏/涂鸦云订阅都写这里),首页卡片
* 以后端这份数据为准。Mesh 本地通道控制时涂鸦 SDK 数据层不会更新,若不上报,
* 首页会一直显示控制前的旧状态。</p>
*/
private void reportDpsToBackend(Map<String, Object> cmd) {
try {
String auth = com.xhai.iot.util.SessionManager.getInstance(this).getBearerToken();
if (auth == null || cmd == null || cmd.isEmpty()) return;
java.util.Map<String, Object> 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<com.xhai.iot.model.Result<Integer>>() {
@Override
public void onResponse(retrofit2.Call<com.xhai.iot.model.Result<Integer>> call,
retrofit2.Response<com.xhai.iot.model.Result<Integer>> response) {
// 通知首页:设备状态已变化,刷新卡片上的开关状态
com.xhai.iot.util.ActivityEventBus.notifyStateChanged(deviceId);
}
@Override
public void onFailure(retrofit2.Call<com.xhai.iot.model.Result<Integer>> 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<String, Object> 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());
}

View File

@ -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 {
*
* <p>用途:面板/物理开关的操作,只有在手机能收到设备 DP 回流时才会被感知
* (此时事件总线会立即通知,不必等心跳);但当操作发生在手机不在家/无蓝牙连接时,
* 只有后端能感知(涂鸦云消息订阅打通后)。心跳作为兜底,让首页在停留期间
* 也能把后端侧新产生的动态拉回来。</p>
* 只有后端能感知。心跳作为兜底,让首页在停留期间也能把后端侧新产生的动态、
* 以及其它入口(面板/中控屏)改动的设备状态拉回来。</p>
*
* <p>只刷新动态列表,不触碰设备列表,因此不会造成常用设备跳位。</p>
* <p>设备列表走轻量刷新(不做探活/重注册监听),且顺序固定、值没变不重绑卡片,
* 因此不会造成常用设备跳位或闪烁。</p>
*/
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<TuyaDevice> toTuyaDevices(List<BackendDevice> list) {
private List<TuyaDevice> toTuyaDevices(List<BackendDevice> list, boolean allowCorrection) {
sdkDeviceDataMissing = false;
List<TuyaDevice> 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<String> lastDeviceOrder = new ArrayList<>();
/**
* 合并后端 DP 值到设备状态(后端优先)。
*
* <p>后端 DP 的键可能是涂鸦 dpId(如 "2"),也可能是物模型 code(如 "switch_2")
* (Mesh 本地通道上报的是 code),这里统一归一化成 dpId,与 SDK 数据层对齐。</p>
*/
private void mergeBackendDps(BackendDevice bd, TuyaDevice td) {
Map<String, Object> backendDps = bd.getBackendDps();
if (backendDps == null || backendDps.isEmpty()) {
return;
}
if (td.getDps() == null) {
td.setDps(new HashMap<>());
}
for (Map.Entry<String, Object> 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<String, String> idToCode = TuyaSdkService.getDpCodeMap(deviceId);
if (idToCode != null) {
for (Map.Entry<String, String> e : idToCode.entrySet()) {
if (key.equals(e.getValue())) {
return e.getKey();
}
}
}
} catch (Exception ignored) {
}
return key;
}
/**
* 按上次顺序重排设备列表。
*

View File

@ -66,4 +66,51 @@ public final class ActivityEventBus {
}
});
}
// ==================== 设备状态变化事件 ====================
/**
* 设备状态变化监听
*
* <p>面板/中控屏操作或本地控制成功后触发,页面据此刷新设备卡片状态
* (开关是开是关)。</p>
*/
public interface StateListener {
void onDeviceStateChanged(String deviceId);
}
private static final List<StateListener> 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<StateListener> snapshot;
synchronized (stateListeners) {
if (stateListeners.isEmpty()) return;
snapshot = new ArrayList<>(stateListeners);
}
MAIN.post(() -> {
for (StateListener l : snapshot) {
try {
l.onDeviceStateChanged(deviceId);
} catch (Exception ignored) {
}
}
});
}
}

View File

@ -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。
*
* <p>device.capabilities 是 APP 读取设备最新 DP 的入口(APP 控制回传、涂鸦云
* 消息订阅也写这里)。中控屏上报的属性(物模型 code)同样写进去,APP 端会把
* code 归一化成涂鸦 dpId 后与 SDK 数据合并,保证卡片开关状态正确。</p>
*/
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<String, Object> 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<String, Object> dps = (caps.get("dps") instanceof Map)
? (Map<String, Object>) 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());
}
}
/**
* 比较状态新旧:世代更大者更新;同世代下序号更大者更新。
*/