fix(U): 修复远程(不在网关附近)打开APP时子设备显示离线且无法控制

- 在线判定: Mesh/Zigbee 子设备没有独立在线通道, 涂鸦云 isOnline 恒 false,
  远程时无本地探活缓存 → 只显示网关在线。改为以「父网关在线」为判定依据
  (后端同样按父网关聚合), 新增 probeOffline 标记支持反向证据
- 控制通道: 蓝牙 Mesh 网关可达时走本地通道, 不可达(远程)时自动改走涂鸦云端下发;
  面板控制同步改造, 本地失败自动回退云端
- 失败提示: Mesh 子设备远程不可控时给出明确指引(现场蓝牙/面板), 不再笼统报控制失败
This commit is contained in:
2995840376 2026-10-28 12:41:32 +08:00
parent 18686dba7c
commit 7e7c1e072c
5 changed files with 251 additions and 47 deletions

View File

@ -132,12 +132,34 @@ public class TuyaPlatformService implements IPlatformService {
ControlCallback callback) { ControlCallback callback) {
try { try {
DeviceBean dev = ThingHomeSdk.getDataInstance().getDeviceBean(deviceId); DeviceBean dev = ThingHomeSdk.getDataInstance().getDeviceBean(deviceId);
// 蓝牙 Mesh 子设备:走 Mesh 本地控制通道(云端 publishDps 对离线 Mesh 子设备会失败) // 蓝牙 Mesh 子设备:
// - 手机在网关附近(蓝牙可达)→ 走 Mesh 本地通道,最快最可靠
// - 远程(不在同一空间/局域网)→ 蓝牙不可达,必须改走涂鸦云端下发
if (dev != null && dev.isSigMesh()) { 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; return;
} }
// 普通设备:云端 publishDps(统一把 DP 数字 id 转换为涂鸦 code) // 普通设备:云端 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<String, Object> commands,
ControlCallback callback, boolean fallbackToMesh) {
try {
String deviceId = dev != null ? dev.getDevId() : "";
IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId); IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId);
Map<String, Object> codeCommands = Map<String, Object> codeCommands =
TuyaSdkService.convertDpIdToCode(deviceId, commands); TuyaSdkService.convertDpIdToCode(deviceId, commands);
@ -150,6 +172,13 @@ public class TuyaPlatformService implements IPlatformService {
@Override @Override
public void onError(String code, String error) { 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); 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<String, Object> commands, private void controlSigMeshDevice(DeviceBean dev, Map<String, Object> commands,
ControlCallback callback) { ControlCallback callback, boolean fallbackToCloud) {
try { try {
String meshId = dev.getMeshId(); String meshId = dev.getMeshId();
String nodeId = dev.getNodeId(); String nodeId = dev.getNodeId();
@ -183,6 +216,12 @@ public class TuyaPlatformService implements IPlatformService {
public void onError(String code, String error) { public void onError(String code, String error) {
Log.e(TAG, "Mesh控制失败 nodeId=" + nodeId + " code=" + code Log.e(TAG, "Mesh控制失败 nodeId=" + nodeId + " code=" + code
+ " err=" + error); + " err=" + error);
if (fallbackToCloud) {
// 蓝牙通道失败 → 回退涂鸦云端(远程场景必须走云端)
Log.i(TAG, "Mesh本地控制失败, 回退云端 devId=" + dev.getDevId());
controlByCloud(dev, commands, callback, false);
return;
}
if (callback != null) callback.onError(code, error); if (callback != null) callback.onError(code, error);
} }
}); });

View File

@ -368,30 +368,46 @@ public class TuyaSdkService {
} }
/** /**
* 综合判断涂鸦设备的在线状态。 * 综合判断涂鸦设备的在线状态(兼容旧调用:不传父网关在线状态)。
* *
* 核心结论(实测数据得出): * @see #resolveDeviceOnline(String, DeviceBean, boolean, String, boolean)
* 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 仅用于日志标识父子关系,不参与在线判定
*/ */
public static boolean resolveDeviceOnline(String devId, public static boolean resolveDeviceOnline(String devId,
com.thingclips.smart.sdk.bean.DeviceBean bean, boolean backendOnline, com.thingclips.smart.sdk.bean.DeviceBean bean, boolean backendOnline,
String parentDevId) { String parentDevId) {
return resolveDeviceOnline(devId, bean, backendOnline, parentDevId, false);
}
/**
* 综合判断涂鸦设备的在线状态。
*
* <p>核心结论(实测数据得出):</p>
* <ol>
* <li><b>子设备(SigMesh/Zigbee)没有独立的在线通道</b>:涂鸦云端对它们的
* isOnline 恒为 false(实测 JD2BTSA/JD6BTSA 通电且网关在线,云端仍 false)。
* 局域网场景下靠"探活确认"缓存能撑 10 分钟,一旦过期或远程打开 APP,
* 就会退回"离线" —— 这正是远程打开 APP 只看到网关在线的原因。
* 因此子设备一律以「父网关在线」为判定依据。</li>
* <li>主设备/网关:云端或局域网任一明确在线即在线;云端明确 false 则离线。</li>
* <li>探活结果优先级最高:确认在线 → 在线;确认离线 → 离线
* (这样插座真断电时仍能识别出来)。</li>
* </ol>
*
* @param parentOnline 父网关当前是否在线(子设备判定用)
*/
public static boolean resolveDeviceOnline(String devId,
com.thingclips.smart.sdk.bean.DeviceBean bean, boolean backendOnline,
String parentDevId, boolean parentOnline) {
// 探活确认期内,设备的真实在线状态已经过通信验证,云端快照不可信 // 探活确认期内,设备的真实在线状态已经过通信验证,云端快照不可信
if (isProbeOnline(devId)) { if (isProbeOnline(devId)) {
Log.i(TAG, "在线判断[" + devId + "] 探活确认期内 → 在线"); Log.i(TAG, "在线判断[" + devId + "] 探活确认期内 → 在线");
return true; return true;
} }
// 探活明确失败 → 判离线(覆盖父网关在线的推断,识别真实断电)
if (isProbeOffline(devId)) {
Log.i(TAG, "在线判断[" + devId + "] 探活确认离线 → 离线");
return false;
}
if (bean == null) { if (bean == null) {
// getDeviceBean() 在家庭详情未同步时恒为 null,回退到 queryHomeList 的设备缓存 // getDeviceBean() 在家庭详情未同步时恒为 null,回退到 queryHomeList 的设备缓存
bean = sdkDeviceCache.get(devId); bean = sdkDeviceCache.get(devId);
@ -412,12 +428,28 @@ public class TuyaSdkService {
Object v = m.invoke(bean); Object v = m.invoke(bean);
if (v instanceof Number) activeTime = ((Number) v).longValue(); if (v instanceof Number) activeTime = ((Number) v).longValue();
} catch (Throwable ignored) {} } catch (Throwable ignored) {}
// 是否子设备:后端父子关系 或 SDK 的 parentId
boolean isSub = (parentDevId != null && !parentDevId.isEmpty() && !parentDevId.equals(devId))
|| isTrueSubDevice(bean);
boolean online; 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; online = true;
} else if (cloud != null) { } else if (cloud != null) {
// 云端明确返回 false → 设备确实离线(子设备断电 / 网关掉线都会是这个值) // 云端明确返回 false → 设备确实离线(主设备断电 / 网关掉线)
online = false; online = false;
} else if (Boolean.FALSE.equals(local)) { } else if (Boolean.FALSE.equals(local)) {
// 云端未知但局域网明确 false:不能据此说离线(手机可能不在同一网络), // 云端未知但局域网明确 false:不能据此说离线(手机可能不在同一网络),
@ -428,9 +460,10 @@ public class TuyaSdkService {
online = backendOnline; online = backendOnline;
} }
Log.i(TAG, "在线判断[" + devId + "/" + bean.getName() + "]" Log.i(TAG, "在线判断[" + devId + "/" + bean.getName() + "]"
+ (isTrueSubDevice(bean) ? "[子设备]" : "[主/网关设备]") + (isSub ? "[子设备]" : "[主/网关设备]")
+ " cloud=" + cloud + " local=" + local + " activeTime=" + activeTime + " cloud=" + cloud + " local=" + local + " activeTime=" + activeTime
+ " backend=" + backendOnline + " → " + online); + " backend=" + backendOnline + " parentOnline=" + parentOnline
+ " → " + online);
return online; return online;
} }
@ -463,6 +496,72 @@ public class TuyaSdkService {
* 因此:探活(下发读属性查询并收到设备上报)确认在线后,在 TTL 内一律视为在线, * 因此:探活(下发读属性查询并收到设备上报)确认在线后,在 TTL 内一律视为在线,
* 云端快照/离线事件在确认期内被忽略。 * 云端快照/离线事件在确认期内被忽略。
*/ */
/**
* 探活确认离线的设备(devId → 确认时间戳 ms):用于识别真实断电。
*
* <p>子设备按父网关在线判定在线后,就失去了识别「插座真断电」的能力;
* 这里用探活失败作为反向证据,短时间内判离线。</p>
*/
private static final java.util.concurrent.ConcurrentHashMap<String, Long> 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(蓝牙网关)本地连接成功的时间。
*
* <p>用于判断手机是否还在网关附近:Mesh 子设备只能通过蓝牙本地通道控制,
* 不在附近时必须改走云端,否则远程操作必然失败。</p>
*/
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<String, Long> onlineOverride = private static final java.util.concurrent.ConcurrentHashMap<String, Long> onlineOverride =
new java.util.concurrent.ConcurrentHashMap<>(); new java.util.concurrent.ConcurrentHashMap<>();
@ -1472,6 +1571,11 @@ public class TuyaSdkService {
public void onConnectStatusChanged(String id, int status) { public void onConnectStatusChanged(String id, int status) {
Log.i(TAG, "SIG Mesh 连接状态变更 meshId=" + id Log.i(TAG, "SIG Mesh 连接状态变更 meshId=" + id
+ " status=" + status); + " status=" + status);
// status: 0=已连接, 1=未连接, 2=连接中(涂鸦 SDK 定义)
if (status == 0) {
// 蓝牙网关可达 → Mesh 子设备可用本地通道控制
markMeshConnected();
}
// 连接成功时通知 UI 刷新(子设备可能已识别为在线) // 连接成功时通知 UI 刷新(子设备可能已识别为在线)
notifyStatusChanged("", true); notifyStatusChanged("", true);
} }

View File

@ -1213,6 +1213,13 @@ public class DeviceControlActivity extends AppCompatActivity {
android.util.Log.i("DeviceControlActivity", android.util.Log.i("DeviceControlActivity",
"Mesh子设备控制 meshId=" + meshId + " nodeId=" + nodeId "Mesh子设备控制 meshId=" + meshId + " nodeId=" + nodeId
+ " category=" + category + " cmd=" + codeCmd); + " category=" + category + " cmd=" + codeCmd);
// 远程场景(不在网关附近):蓝牙通道不可达,直接走涂鸦云端
if (!TuyaSdkService.isMeshReachable()) {
android.util.Log.i("DeviceControlActivity",
"蓝牙网关不可达, 直接走云端下发 device=" + deviceId);
sendCloudCommand(dev, cmd, codeCmd);
return;
}
IThingBlueMeshDevice meshDev = ThingOSMesh.newDevice(meshId); IThingBlueMeshDevice meshDev = ThingOSMesh.newDevice(meshId);
meshDev.publishCommands(nodeId, category, codeCmd, new IResultCallback() { meshDev.publishCommands(nodeId, category, codeCmd, new IResultCallback() {
@Override @Override
@ -1230,10 +1237,31 @@ public class DeviceControlActivity extends AppCompatActivity {
"Mesh控制失败 device=" + deviceId + " code=" + code "Mesh控制失败 device=" + deviceId + " code=" + code
+ " error=" + error + ",回退云端 publishDps"); + " error=" + error + ",回退云端 publishDps");
// Mesh 本地通道失败(设备未连蓝牙/Mesh 离线,或被误判为 Mesh):回退云端控制 // Mesh 本地通道失败(设备未连蓝牙/Mesh 离线,或被误判为 Mesh):回退云端控制
sendCloudCommand(dev, cmd, codeCmd);
}
});
} catch (Exception e) {
android.util.Log.e("DeviceControlActivity", "Mesh控制异常: " + e.getMessage(), e);
runOnUiThread(() -> Toast.makeText(DeviceControlActivity.this,
"控制失败: " + e.getMessage(), Toast.LENGTH_LONG).show());
}
}
/**
* 涂鸦云端下发指令(远程控制路径)。
*
* <p>Mesh 子设备若涂鸦云不支持经网关透传,这里会失败,此时给出明确提示
* (该设备是蓝牙 Mesh 子设备,需在现场控制),而不是笼统的"控制失败"。</p>
*/
private void sendCloudCommand(DeviceBean device, Map<String, Object> cmd,
Map<String, Object> codeCmd) {
try {
device.publishDps(JSONObject.toJSONString(codeCmd), device.publishDps(JSONObject.toJSONString(codeCmd),
new IResultCallback() { new IResultCallback() {
@Override @Override
public void onSuccess() { public void onSuccess() {
reportControlActivity(cmd);
reportDpsToBackend(codeCmd);
runOnUiThread(() -> Toast.makeText( runOnUiThread(() -> Toast.makeText(
DeviceControlActivity.this, DeviceControlActivity.this,
"指令已下发", Toast.LENGTH_SHORT).show()); "指令已下发", Toast.LENGTH_SHORT).show());
@ -1243,18 +1271,17 @@ public class DeviceControlActivity extends AppCompatActivity {
public void onError(String c2, String e2) { public void onError(String c2, String e2) {
android.util.Log.e("DeviceControlActivity", android.util.Log.e("DeviceControlActivity",
"云端控制失败 device=" + deviceId "云端控制失败 device=" + deviceId
+ " code=" + c2 + " error=" + e2 + " code=" + c2 + " error=" + e2);
+ " cmd=" + json); boolean mesh = device != null && device.isSigMesh();
String msg = mesh
? "该设备是蓝牙 Mesh 子设备,远程无法直接控制。\n"
+ "请到网关附近用手机蓝牙控制,或使用现场面板。"
: "控制失败: " + (e2 != null ? e2 : c2);
runOnUiThread(() -> Toast.makeText( runOnUiThread(() -> Toast.makeText(
DeviceControlActivity.this, DeviceControlActivity.this, msg, Toast.LENGTH_LONG).show());
"控制失败: " + (e2 != null ? e2 : c2),
Toast.LENGTH_LONG).show());
}
});
} }
}); });
} catch (Exception e) { } catch (Exception e) {
android.util.Log.e("DeviceControlActivity", "Mesh控制异常: " + e.getMessage(), e);
runOnUiThread(() -> Toast.makeText(DeviceControlActivity.this, runOnUiThread(() -> Toast.makeText(DeviceControlActivity.this,
"控制失败: " + e.getMessage(), Toast.LENGTH_LONG).show()); "控制失败: " + e.getMessage(), Toast.LENGTH_LONG).show());
} }

View File

@ -1255,8 +1255,33 @@ public class HomeFragment extends Fragment {
private List<TuyaDevice> toTuyaDevices(List<BackendDevice> list, boolean allowCorrection) { private List<TuyaDevice> toTuyaDevices(List<BackendDevice> list, boolean allowCorrection) {
sdkDeviceDataMissing = false; sdkDeviceDataMissing = false;
List<TuyaDevice> result = new ArrayList<>(); List<TuyaDevice> result = new ArrayList<>();
// 父设备在线映射(网关):子设备在线判定依赖它
Map<String, Boolean> 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) { for (BackendDevice bd : list) {
TuyaDevice td = new TuyaDevice(); 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.setId(bd.getDeviceId());
td.setName(bd.getDeviceName()); td.setName(bd.getDeviceName());
td.setOnline(bd.isOnline()); td.setOnline(bd.isOnline());
@ -1278,7 +1303,8 @@ public class HomeFragment extends Fragment {
// 子设备用自身的 getIsOnline()/getIsLocalOnline(), // 子设备用自身的 getIsOnline()/getIsLocalOnline(),
// 不能因为父网关在线就推断子设备在线(子设备可能断电)。 // 不能因为父网关在线就推断子设备在线(子设备可能断电)。
boolean sdkOnline = TuyaSdkService.resolveDeviceOnline( boolean sdkOnline = TuyaSdkService.resolveDeviceOnline(
bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId()); bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId(),
parentOnline);
td.setOnline(sdkOnline); td.setOnline(sdkOnline);
// 与后端不一致时回写纠正,保证下次进入/其他页面也准确 // 与后端不一致时回写纠正,保证下次进入/其他页面也准确
if (sdkOnline != bd.isOnline() && allowCorrection) { if (sdkOnline != bd.isOnline() && allowCorrection) {

View File

@ -254,10 +254,18 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
public void onError(String code, String error) { public void onError(String code, String error) {
unlockPendingToggle(device.getId()); unlockPendingToggle(device.getId());
holder.pill.post(() -> holder.pill.setChecked(!checked)); holder.pill.post(() -> 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( android.widget.Toast.makeText(
holder.itemView.getContext(), holder.itemView.getContext(), msg,
"控制失败: " + (error != null ? error : code), android.widget.Toast.LENGTH_LONG).show();
android.widget.Toast.LENGTH_SHORT).show();
} }
}); });
holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, checked, false)); holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, checked, false));