From 7e7c1e072cf5eebcdcaa349aa8cc940f8c3953cc Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Wed, 28 Oct 2026 12:41:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(U):=20=E4=BF=AE=E5=A4=8D=E8=BF=9C=E7=A8=8B(?= =?UTF-8?q?=E4=B8=8D=E5=9C=A8=E7=BD=91=E5=85=B3=E9=99=84=E8=BF=91)?= =?UTF-8?q?=E6=89=93=E5=BC=80APP=E6=97=B6=E5=AD=90=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E7=A6=BB=E7=BA=BF=E4=B8=94=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在线判定: Mesh/Zigbee 子设备没有独立在线通道, 涂鸦云 isOnline 恒 false, 远程时无本地探活缓存 → 只显示网关在线。改为以「父网关在线」为判定依据 (后端同样按父网关聚合), 新增 probeOffline 标记支持反向证据 - 控制通道: 蓝牙 Mesh 网关可达时走本地通道, 不可达(远程)时自动改走涂鸦云端下发; 面板控制同步改造, 本地失败自动回退云端 - 失败提示: Mesh 子设备远程不可控时给出明确指引(现场蓝牙/面板), 不再笼统报控制失败 --- .../xhai/iot/service/TuyaPlatformService.java | 47 +++++- .../com/xhai/iot/service/TuyaSdkService.java | 140 +++++++++++++++--- .../xhai/iot/ui/DeviceControlActivity.java | 69 ++++++--- .../java/com/xhai/iot/ui/HomeFragment.java | 28 +++- .../iot/ui/adapter/HomeDeviceAdapter.java | 14 +- 5 files changed, 251 insertions(+), 47 deletions(-) diff --git a/Android/app/src/main/java/com/xhai/iot/service/TuyaPlatformService.java b/Android/app/src/main/java/com/xhai/iot/service/TuyaPlatformService.java index 58c3076..ee1b0a3 100644 --- a/Android/app/src/main/java/com/xhai/iot/service/TuyaPlatformService.java +++ b/Android/app/src/main/java/com/xhai/iot/service/TuyaPlatformService.java @@ -132,12 +132,34 @@ public class TuyaPlatformService implements IPlatformService { ControlCallback callback) { try { DeviceBean dev = ThingHomeSdk.getDataInstance().getDeviceBean(deviceId); - // 蓝牙 Mesh 子设备:走 Mesh 本地控制通道(云端 publishDps 对离线 Mesh 子设备会失败) + // 蓝牙 Mesh 子设备: + // - 手机在网关附近(蓝牙可达)→ 走 Mesh 本地通道,最快最可靠 + // - 远程(不在同一空间/局域网)→ 蓝牙不可达,必须改走涂鸦云端下发 if (dev != null && dev.isSigMesh()) { - controlSigMeshDevice(dev, commands, callback); + if (TuyaSdkService.isMeshReachable()) { + controlSigMeshDevice(dev, commands, callback, true); + } else { + Log.i(TAG, "Mesh网关不可达, 改走云端下发 devId=" + deviceId); + controlByCloud(dev, commands, callback, false); + } return; } // 普通设备:云端 publishDps(统一把 DP 数字 id 转换为涂鸦 code) + controlByCloud(dev, commands, callback, false); + } catch (Exception e) { + if (callback != null) callback.onError("ex", e.getMessage()); + } + } + + /** + * 云端下发指令。 + * + * @param fallbackToMesh 云端失败后是否回退到 Mesh 本地通道 + */ + private void controlByCloud(DeviceBean dev, Map commands, + ControlCallback callback, boolean fallbackToMesh) { + try { + String deviceId = dev != null ? dev.getDevId() : ""; IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId); Map codeCommands = TuyaSdkService.convertDpIdToCode(deviceId, commands); @@ -150,6 +172,13 @@ public class TuyaPlatformService implements IPlatformService { @Override public void onError(String code, String error) { + Log.w(TAG, "云端下发失败 devId=" + deviceId + " code=" + code + + " err=" + error); + if (fallbackToMesh && dev != null && dev.isSigMesh()) { + // 蓝牙可达但云端失败(如设备仅本地在线)→ 回退 Mesh 本地通道 + controlSigMeshDevice(dev, commands, callback, false); + return; + } if (callback != null) callback.onError(code, error); } }); @@ -158,9 +187,13 @@ public class TuyaPlatformService implements IPlatformService { } } - /** 控制蓝牙 Mesh 子设备(走 Mesh 本地通道) */ + /** + * 控制蓝牙 Mesh 子设备(走 Mesh 本地通道)。 + * + * @param fallbackToCloud 本地通道失败后是否回退云端(远程时蓝牙不可达) + */ private void controlSigMeshDevice(DeviceBean dev, Map commands, - ControlCallback callback) { + ControlCallback callback, boolean fallbackToCloud) { try { String meshId = dev.getMeshId(); String nodeId = dev.getNodeId(); @@ -183,6 +216,12 @@ public class TuyaPlatformService implements IPlatformService { public void onError(String code, String error) { Log.e(TAG, "Mesh控制失败 nodeId=" + nodeId + " code=" + code + " err=" + error); + if (fallbackToCloud) { + // 蓝牙通道失败 → 回退涂鸦云端(远程场景必须走云端) + Log.i(TAG, "Mesh本地控制失败, 回退云端 devId=" + dev.getDevId()); + controlByCloud(dev, commands, callback, false); + return; + } if (callback != null) callback.onError(code, error); } }); diff --git a/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java b/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java index 8a7ff6a..9d071f4 100644 --- a/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java +++ b/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java @@ -368,30 +368,46 @@ public class TuyaSdkService { } /** - * 综合判断涂鸦设备的在线状态。 + * 综合判断涂鸦设备的在线状态(兼容旧调用:不传父网关在线状态)。 * - * 核心结论(实测数据得出): - * 1. **不能用「父网关在线 ⇒ 子设备在线」推断。** - * 网关 301网关 IsOnline=true,但其下 JD6BTSA 的 IsOnline=false —— 网关在线, - * 插座断电时子设备依然是离线。涂鸦云端会独立维护每个设备的在线状态。 - * 2. **在线判定只信设备自己的 IsOnline()/IsLocalOnline()。** - * 实测值:301网关 IsOnline=true;DMD3CC/窗帘开关/JD6BTSA IsOnline=false, - * 与涂鸦 App 显示一致。 - * 3. IsLocalOnline 只表示「手机和该设备在同一局域网可直连」,本项目手机与设备 - * 不在同一网络(设备 Ip 是公网),所以它恒为 false,**不能**当成离线依据; - * 但为 true 时说明设备就在身边,也算在线。 - * 4. 数据层未同步时 IsOnline 可能是 null(不是 false),此时才回退后端 status。 - * - * @param parentDevId 仅用于日志标识父子关系,不参与在线判定 + * @see #resolveDeviceOnline(String, DeviceBean, boolean, String, boolean) */ public static boolean resolveDeviceOnline(String devId, com.thingclips.smart.sdk.bean.DeviceBean bean, boolean backendOnline, String parentDevId) { + return resolveDeviceOnline(devId, bean, backendOnline, parentDevId, false); + } + + /** + * 综合判断涂鸦设备的在线状态。 + * + *

核心结论(实测数据得出):

+ *
    + *
  1. 子设备(SigMesh/Zigbee)没有独立的在线通道:涂鸦云端对它们的 + * isOnline 恒为 false(实测 JD2BTSA/JD6BTSA 通电且网关在线,云端仍 false)。 + * 局域网场景下靠"探活确认"缓存能撑 10 分钟,一旦过期或远程打开 APP, + * 就会退回"离线" —— 这正是远程打开 APP 只看到网关在线的原因。 + * 因此子设备一律以「父网关在线」为判定依据。
  2. + *
  3. 主设备/网关:云端或局域网任一明确在线即在线;云端明确 false 则离线。
  4. + *
  5. 探活结果优先级最高:确认在线 → 在线;确认离线 → 离线 + * (这样插座真断电时仍能识别出来)。
  6. + *
+ * + * @param parentOnline 父网关当前是否在线(子设备判定用) + */ + public static boolean resolveDeviceOnline(String devId, + com.thingclips.smart.sdk.bean.DeviceBean bean, boolean backendOnline, + String parentDevId, boolean parentOnline) { // 探活确认期内,设备的真实在线状态已经过通信验证,云端快照不可信 if (isProbeOnline(devId)) { Log.i(TAG, "在线判断[" + devId + "] 探活确认期内 → 在线"); return true; } + // 探活明确失败 → 判离线(覆盖父网关在线的推断,识别真实断电) + if (isProbeOffline(devId)) { + Log.i(TAG, "在线判断[" + devId + "] 探活确认离线 → 离线"); + return false; + } if (bean == null) { // getDeviceBean() 在家庭详情未同步时恒为 null,回退到 queryHomeList 的设备缓存 bean = sdkDeviceCache.get(devId); @@ -412,12 +428,28 @@ public class TuyaSdkService { Object v = m.invoke(bean); if (v instanceof Number) activeTime = ((Number) v).longValue(); } catch (Throwable ignored) {} + + // 是否子设备:后端父子关系 或 SDK 的 parentId + boolean isSub = (parentDevId != null && !parentDevId.isEmpty() && !parentDevId.equals(devId)) + || isTrueSubDevice(bean); + boolean online; - if (Boolean.TRUE.equals(cloud) || Boolean.TRUE.equals(local)) { + if (isSub) { + // 子设备:云端 isOnline 恒 false,不能采信。 + // 以父网关在线为判定依据;父网关离线才判离线。 + if (parentOnline) { + online = true; + } else if (Boolean.TRUE.equals(cloud) || Boolean.TRUE.equals(local)) { + online = true; + } else { + // 父网关不在线 → 采信后端结论(后端同样按父网关聚合过) + online = backendOnline; + } + } else if (Boolean.TRUE.equals(cloud) || Boolean.TRUE.equals(local)) { // 云端或局域网任一明确在线 → 在线 online = true; } else if (cloud != null) { - // 云端明确返回 false → 设备确实离线(子设备断电 / 网关掉线都会是这个值) + // 云端明确返回 false → 设备确实离线(主设备断电 / 网关掉线) online = false; } else if (Boolean.FALSE.equals(local)) { // 云端未知但局域网明确 false:不能据此说离线(手机可能不在同一网络), @@ -428,9 +460,10 @@ public class TuyaSdkService { online = backendOnline; } Log.i(TAG, "在线判断[" + devId + "/" + bean.getName() + "]" - + (isTrueSubDevice(bean) ? "[子设备]" : "[主/网关设备]") + + (isSub ? "[子设备]" : "[主/网关设备]") + " cloud=" + cloud + " local=" + local + " activeTime=" + activeTime - + " backend=" + backendOnline + " → " + online); + + " backend=" + backendOnline + " parentOnline=" + parentOnline + + " → " + online); return online; } @@ -463,6 +496,72 @@ public class TuyaSdkService { * 因此:探活(下发读属性查询并收到设备上报)确认在线后,在 TTL 内一律视为在线, * 云端快照/离线事件在确认期内被忽略。 */ + /** + * 探活确认离线的设备(devId → 确认时间戳 ms):用于识别真实断电。 + * + *

子设备按父网关在线判定在线后,就失去了识别「插座真断电」的能力; + * 这里用探活失败作为反向证据,短时间内判离线。

+ */ + private static final java.util.concurrent.ConcurrentHashMap offlineOverride = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** 探活确认离线的有效期(毫秒):比在线确认期短,避免一次误判长期离线 */ + private static final long OFFLINE_OVERRIDE_TTL_MS = 3 * 60 * 1000L; + + /** 标记探活确认离线 */ + public static void markProbeOffline(String devId) { + if (devId != null && !devId.isEmpty()) { + offlineOverride.put(devId, System.currentTimeMillis()); + } + } + + /** 是否处于探活确认离线期内 */ + public static boolean isProbeOffline(String devId) { + if (devId == null) return false; + Long t = offlineOverride.get(devId); + if (t == null) return false; + if (System.currentTimeMillis() - t > OFFLINE_OVERRIDE_TTL_MS) { + offlineOverride.remove(devId); + return false; + } + return true; + } + + /** + * 最近一次 Mesh(蓝牙网关)本地连接成功的时间。 + * + *

用于判断手机是否还在网关附近:Mesh 子设备只能通过蓝牙本地通道控制, + * 不在附近时必须改走云端,否则远程操作必然失败。

+ */ + private static volatile long lastMeshConnectedAt = 0L; + + /** 标记 Mesh 网关本地连接成功 */ + public static void markMeshConnected() { + lastMeshConnectedAt = System.currentTimeMillis(); + } + + /** 蓝牙 Mesh 网关是否在可达范围内(10 分钟内有成功连接) */ + public static boolean isMeshReachable() { + if (lastMeshConnectedAt <= 0) return false; + return System.currentTimeMillis() - lastMeshConnectedAt < 10 * 60 * 1000L; + } + + /** 是否是蓝牙 Mesh 子设备(只能通过网关附近蓝牙控制) */ + public static boolean isSigMeshDevice(String deviceId) { + if (deviceId == null) return false; + try { + com.thingclips.smart.sdk.bean.DeviceBean bean = + com.thingclips.smart.home.sdk.ThingHomeSdk.getDataInstance() + .getDeviceBean(deviceId); + if (bean == null) { + bean = sdkDeviceCache.get(deviceId); + } + return bean != null && bean.isSigMesh(); + } catch (Throwable e) { + return false; + } + } + private static final java.util.concurrent.ConcurrentHashMap onlineOverride = new java.util.concurrent.ConcurrentHashMap<>(); @@ -1472,6 +1571,11 @@ public class TuyaSdkService { public void onConnectStatusChanged(String id, int status) { Log.i(TAG, "SIG Mesh 连接状态变更 meshId=" + id + " status=" + status); + // status: 0=已连接, 1=未连接, 2=连接中(涂鸦 SDK 定义) + if (status == 0) { + // 蓝牙网关可达 → Mesh 子设备可用本地通道控制 + markMeshConnected(); + } // 连接成功时通知 UI 刷新(子设备可能已识别为在线) notifyStatusChanged("", true); } 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 b19c1bd..841ea6d 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 @@ -1213,6 +1213,13 @@ public class DeviceControlActivity extends AppCompatActivity { android.util.Log.i("DeviceControlActivity", "Mesh子设备控制 meshId=" + meshId + " nodeId=" + nodeId + " category=" + category + " cmd=" + codeCmd); + // 远程场景(不在网关附近):蓝牙通道不可达,直接走涂鸦云端 + if (!TuyaSdkService.isMeshReachable()) { + android.util.Log.i("DeviceControlActivity", + "蓝牙网关不可达, 直接走云端下发 device=" + deviceId); + sendCloudCommand(dev, cmd, codeCmd); + return; + } IThingBlueMeshDevice meshDev = ThingOSMesh.newDevice(meshId); meshDev.publishCommands(nodeId, category, codeCmd, new IResultCallback() { @Override @@ -1230,27 +1237,7 @@ public class DeviceControlActivity extends AppCompatActivity { "Mesh控制失败 device=" + deviceId + " code=" + code + " error=" + error + ",回退云端 publishDps"); // Mesh 本地通道失败(设备未连蓝牙/Mesh 离线,或被误判为 Mesh):回退云端控制 - device.publishDps(JSONObject.toJSONString(codeCmd), - new IResultCallback() { - @Override - public void onSuccess() { - runOnUiThread(() -> Toast.makeText( - DeviceControlActivity.this, - "指令已下发", Toast.LENGTH_SHORT).show()); - } - - @Override - public void onError(String c2, String e2) { - android.util.Log.e("DeviceControlActivity", - "云端控制失败 device=" + deviceId - + " code=" + c2 + " error=" + e2 - + " cmd=" + json); - runOnUiThread(() -> Toast.makeText( - DeviceControlActivity.this, - "控制失败: " + (e2 != null ? e2 : c2), - Toast.LENGTH_LONG).show()); - } - }); + sendCloudCommand(dev, cmd, codeCmd); } }); } catch (Exception e) { @@ -1260,6 +1247,46 @@ public class DeviceControlActivity extends AppCompatActivity { } } + /** + * 涂鸦云端下发指令(远程控制路径)。 + * + *

Mesh 子设备若涂鸦云不支持经网关透传,这里会失败,此时给出明确提示 + * (该设备是蓝牙 Mesh 子设备,需在现场控制),而不是笼统的"控制失败"。

+ */ + private void sendCloudCommand(DeviceBean device, Map cmd, + Map codeCmd) { + try { + device.publishDps(JSONObject.toJSONString(codeCmd), + new IResultCallback() { + @Override + public void onSuccess() { + reportControlActivity(cmd); + reportDpsToBackend(codeCmd); + runOnUiThread(() -> Toast.makeText( + DeviceControlActivity.this, + "指令已下发", Toast.LENGTH_SHORT).show()); + } + + @Override + public void onError(String c2, String e2) { + android.util.Log.e("DeviceControlActivity", + "云端控制失败 device=" + deviceId + + " code=" + c2 + " error=" + e2); + boolean mesh = device != null && device.isSigMesh(); + String msg = mesh + ? "该设备是蓝牙 Mesh 子设备,远程无法直接控制。\n" + + "请到网关附近用手机蓝牙控制,或使用现场面板。" + : "控制失败: " + (e2 != null ? e2 : c2); + runOnUiThread(() -> Toast.makeText( + DeviceControlActivity.this, msg, Toast.LENGTH_LONG).show()); + } + }); + } catch (Exception e) { + runOnUiThread(() -> Toast.makeText(DeviceControlActivity.this, + "控制失败: " + e.getMessage(), Toast.LENGTH_LONG).show()); + } + } + private void updateOnlineStatus(DeviceBean deviceBean) { boolean online = false; if (deviceBean != null) { 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 ab9240c..4dc721a 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 @@ -1255,8 +1255,33 @@ public class HomeFragment extends Fragment { private List toTuyaDevices(List list, boolean allowCorrection) { sdkDeviceDataMissing = false; List result = new ArrayList<>(); + // 父设备在线映射(网关):子设备在线判定依赖它 + Map parentOnlineMap = new HashMap<>(); + for (BackendDevice d : list) { + if (d.getDeviceId() == null) continue; + boolean isParent = (d.getIsGateway() != null && d.getIsGateway() == 1); + if (!isParent) { + for (BackendDevice c : list) { + if (d.getDeviceId().equals(c.getParentDeviceId())) { + isParent = true; + break; + } + } + } + if (isParent) { + parentOnlineMap.put(d.getDeviceId(), d.isOnline()); + } + } for (BackendDevice bd : list) { TuyaDevice td = new TuyaDevice(); + // 父网关在线映射:Mesh/Zigbee 子设备没有独立在线通道, + // 涂鸦云端对它们的 isOnline 恒为 false,远程打开 APP 时会误判离线, + // 因此子设备一律以「父网关在线」为判定依据。 + boolean parentOnline = false; + if (bd.getParentDeviceId() != null && !bd.getParentDeviceId().isEmpty()) { + Boolean p = parentOnlineMap.get(bd.getParentDeviceId()); + parentOnline = Boolean.TRUE.equals(p); + } td.setId(bd.getDeviceId()); td.setName(bd.getDeviceName()); td.setOnline(bd.isOnline()); @@ -1278,7 +1303,8 @@ public class HomeFragment extends Fragment { // 子设备用自身的 getIsOnline()/getIsLocalOnline(), // 不能因为父网关在线就推断子设备在线(子设备可能断电)。 boolean sdkOnline = TuyaSdkService.resolveDeviceOnline( - bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId()); + bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId(), + parentOnline); td.setOnline(sdkOnline); // 与后端不一致时回写纠正,保证下次进入/其他页面也准确 if (sdkOnline != bd.isOnline() && allowCorrection) { diff --git a/Android/app/src/main/java/com/xhai/iot/ui/adapter/HomeDeviceAdapter.java b/Android/app/src/main/java/com/xhai/iot/ui/adapter/HomeDeviceAdapter.java index f46d2d3..3f901cf 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/adapter/HomeDeviceAdapter.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/adapter/HomeDeviceAdapter.java @@ -254,10 +254,18 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter holder.pill.setChecked(!checked)); + // 蓝牙 Mesh 子设备远程不可控:给出可操作的提示 + String msg; + if (TuyaSdkService.isSigMeshDevice(device.getId()) + && !TuyaSdkService.isMeshReachable()) { + msg = "该设备是蓝牙 Mesh 子设备,远程无法直接控制。\n" + + "请到网关附近用手机蓝牙控制,或使用现场面板。"; + } else { + msg = "控制失败: " + (error != null ? error : code); + } android.widget.Toast.makeText( - holder.itemView.getContext(), - "控制失败: " + (error != null ? error : code), - android.widget.Toast.LENGTH_SHORT).show(); + holder.itemView.getContext(), msg, + android.widget.Toast.LENGTH_LONG).show(); } }); holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, checked, false));