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 a78765f..9c7cc58 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 @@ -111,6 +111,7 @@ public class HomeFragment extends Fragment { if (deviceRefreshHandler != null) { deviceRefreshHandler.removeCallbacks(alignRefreshTask); } + stopHeartbeat(); } @Override @@ -125,18 +126,31 @@ public class HomeFragment extends Fragment { loadDevicesFromBackend(); // 兜底:页面回到前台时同步一次近期动态(覆盖进程内事件丢失的情况) scheduleActivityRefresh(); + // 页面可见:启动动态心跳(兜底拉取后端侧产生的动态,如面板操作) + startHeartbeat(); } } + @Override + public void onPause() { + super.onPause(); + stopHeartbeat(); + } + /** Tab 用 hide/show 切换时不走 onResume,需在此刷新主题色 */ @Override public void onHiddenChanged(boolean hidden) { super.onHiddenChanged(hidden); - if (!hidden && adapter != null && isAdded()) { + if (hidden) { + stopHeartbeat(); + return; + } + if (adapter != null && isAdded()) { adapter.setAccentColor(ThemeManager.getInstance(requireContext()).accent().value); adapter.notifyDataSetChanged(); // 兜底:切回首页时同步一次近期动态 scheduleActivityRefresh(); + startHeartbeat(); } } @@ -164,6 +178,42 @@ public class HomeFragment extends Fragment { activityRefreshHandler.postDelayed(activityRefreshTask, ACTIVITY_REFRESH_DEBOUNCE_MS); } + // ==================== 近期动态心跳 ==================== + + /** + * 心跳间隔:30 秒。 + * + *
用途:面板/物理开关的操作,只有在手机能收到设备 DP 回流时才会被感知 + * (此时事件总线会立即通知,不必等心跳);但当操作发生在手机不在家/无蓝牙连接时, + * 只有后端能感知(涂鸦云消息订阅打通后)。心跳作为兜底,让首页在停留期间 + * 也能把后端侧新产生的动态拉回来。
+ * + *只刷新动态列表,不触碰设备列表,因此不会造成常用设备跳位。
+ */ + private static final long ACTIVITY_HEARTBEAT_MS = 30_000L; + private final android.os.Handler heartbeatHandler = + new android.os.Handler(android.os.Looper.getMainLooper()); + private final Runnable heartbeatTask = new Runnable() { + @Override + public void run() { + if (isAdded() && getView() != null) { + loadRecentActivities(); + } + heartbeatHandler.postDelayed(this, ACTIVITY_HEARTBEAT_MS); + } + }; + + /** 启动心跳(页面可见时) */ + private void startHeartbeat() { + heartbeatHandler.removeCallbacks(heartbeatTask); + heartbeatHandler.postDelayed(heartbeatTask, ACTIVITY_HEARTBEAT_MS); + } + + /** 停止心跳(页面不可见时) */ + private void stopHeartbeat() { + heartbeatHandler.removeCallbacks(heartbeatTask); + } + // ==================== 刷新节流(避免高频全量重绑导致界面闪动) ==================== /** diff --git a/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java b/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java index ff60dc7..284148f 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java @@ -111,6 +111,16 @@ public class DeviceActivityService { String type = StringUtils.hasText(event.getType()) ? event.getType() : (isControl ? "control" : "state"); + // 1.5) 主动控制 ↔ 回执 配对去重 + // APP 主动控制时先落一条 source=app 的动态,随后设备把同一变化回传上来 + // (source=panel)。二者是同一次操作,用户只应看到一条「APP 操作」。 + // 这里按「同设备 + 同物模型属性 + 同动作 + 时间窗内已有主动控制」判定为回执并跳过。 + if (isPassiveFeedback(event, type) && isPairedWithActiveCommand(device, s)) { + log.debug("回执已与主动控制配对,跳过重复动态: deviceId={} dp={} action={}", + device.getDeviceId(), s.dpCode, s.action); + return; + } + // 2) 去重键 String dedupeKey = computeDedupeKey(event, s); @@ -265,6 +275,80 @@ public class DeviceActivityService { return event.getDeviceId() + ":" + dp + ":" + val + ":" + slot; } + // ==================== 主动控制 ↔ 回执 配对去重 ==================== + + /** 主动控制来源(这些来源算「我方发起的操作」,其回执不应重复展示) */ + private static final java.util.Set面板操作、物理开关按下、涂鸦原生面板、云端 DP 上报都归到这里。
+ */ + private boolean isPassiveFeedback(DeviceEvent event, String type) { + if (!"state".equals(type)) { + return false; + } + String source = event.getSource(); + return "panel".equals(source) || "local".equals(source); + } + + /** + * 判断这条回执是否与近期的主动控制是同一次操作。 + * + *判据:同设备、同物模型属性、同动作(调节类再比值),且时间窗内已存在 + * app/voice/scene 来源的主动控制记录。
+ */ + private boolean isPairedWithActiveCommand(Device device, Semantic s) { + // 回执与 APP 主动上报几乎同时到达,稍等一下再判定,避免"先到先判"漏配对 + try { + Thread.sleep(FEEDBACK_PAIR_DELAY_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + try { + java.util.List