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()); + } + } + /** * 比较状态新旧:世代更大者更新;同世代下序号更大者更新。 */