feat(S): 支持多路开关设备(两位插座/四位排插)的状态判断与一键控制

- TuyaSdkService 新增 SwitchState/readSwitchState: 按电源开关通道聚合,
  场景/背光/指示灯/倒计时等非电源开关按名称与分数排除
  (JD6BTSA 4位开关+2位场景开关 -> 只识别4路电源开关; JD2BTSA -> 2路)
- 卡片状态: 任一路开=已打开, 部分开=部分开启, 全关=已关闭
  (此前只读第一个通道, 打开另一路时首页仍显示已关闭)
- 卡片点击: 多路设备一键控制全部电源通道
- 乐观值锁定与快捷统计同步改为多路聚合
- 设备页(DevicesFragment/DeviceGridAdapter)同步合并后端最新DP与多路判断
This commit is contained in:
2995840376 2026-10-28 11:41:58 +08:00
parent a894013de7
commit 15534a3b11
5 changed files with 222 additions and 63 deletions

View File

@ -841,6 +841,98 @@ public class TuyaSdkService {
private static final java.util.Map<String, java.util.Map<String, String>> DP_CODE_CACHE = private static final java.util.Map<String, java.util.Map<String, String>> DP_CODE_CACHE =
new java.util.concurrent.ConcurrentHashMap<>(); new java.util.concurrent.ConcurrentHashMap<>();
// ==================== 多路开关聚合(两位插座 / 四位排插) ====================
/**
* 设备开关聚合状态。
*
* <p>插座/排插有多路独立电源开关(JD2BTSA 两路、JD6BTSA 四路),
* 卡片只有一个胶囊,必须明确"显示的是什么":
* 任一路开 → 视为已打开;部分开 → 部分开启;全关 → 已关闭。</p>
*/
public static class SwitchState {
/** 识别到的电源开关通道数 */
public final int channelCount;
/** 有明确取值的通道数 */
public final int knownCount;
/** 已打开的通道数 */
public final int onCount;
SwitchState(int channelCount, int knownCount, int onCount) {
this.channelCount = channelCount;
this.knownCount = knownCount;
this.onCount = onCount;
}
/** 是否至少有一路打开 */
public boolean isAnyOn() {
return onCount > 0;
}
/** 是否全部打开(已知通道全开) */
public boolean isAllOn() {
return knownCount > 0 && onCount == knownCount;
}
/** 是否"部分开启"(多路设备,既有开又有关) */
public boolean isPartial() {
return channelCount > 1 && onCount > 0 && knownCount > 1 && onCount < knownCount;
}
/** 是否有可用的状态取值 */
public boolean isKnown() {
return knownCount > 0;
}
}
/**
* 读取设备的开关聚合状态。
*
* <p>通道识别复用 {@link #findSwitchChannels(String)}(已排除场景、背光、指示灯、
* 倒计时等非电源开关:JD6BTSA 的"4 位开关 + 2 位场景开关"只会取到 4 路电源开关)。</p>
*
* @param deviceId 设备ID
* @param dps DP 值集合(键为 dpId,兼容物模型 code)
*/
public static SwitchState readSwitchState(String deviceId, Map<String, Object> dps) {
List<SwitchChannel> channels = findSwitchChannels(deviceId);
int known = 0;
int on = 0;
for (SwitchChannel ch : channels) {
Boolean v = readChannelValue(dps, ch);
if (v == null) continue;
known++;
if (v) on++;
}
return new SwitchState(channels.size(), known, on);
}
/** 读取单个开关通道的值(dpId 优先,兼容 code 键与字符串型状态) */
public static Boolean readChannelValue(Map<String, Object> dps, SwitchChannel ch) {
if (dps == null || dps.isEmpty() || ch == null) {
return null;
}
Object v = dps.get(ch.dpId);
if (v == null && ch.code != null) {
v = dps.get(ch.code);
}
if (v instanceof Boolean) {
return (Boolean) v;
}
if (v instanceof String) {
String s = ((String) v).trim();
if ("true".equalsIgnoreCase(s) || "on".equalsIgnoreCase(s)
|| "power_on".equalsIgnoreCase(s) || "opened".equalsIgnoreCase(s)) {
return Boolean.TRUE;
}
if ("false".equalsIgnoreCase(s) || "off".equalsIgnoreCase(s)
|| "power_off".equalsIgnoreCase(s) || "closed".equalsIgnoreCase(s)) {
return Boolean.FALSE;
}
}
return null;
}
/** 清除全部开关 DP 缓存(设备重新配网/schema 变化后调用) */ /** 清除全部开关 DP 缓存(设备重新配网/schema 变化后调用) */
public static void clearSwitchDpCache() { public static void clearSwitchDpCache() {
SWITCH_DP_CACHE.clear(); SWITCH_DP_CACHE.clear();

View File

@ -611,7 +611,7 @@ public class DevicesFragment extends Fragment {
} }
} }
if (devBean.getDps() != null) { if (devBean.getDps() != null) {
td.setDps(devBean.getDps()); td.setDps(new HashMap<>(devBean.getDps()));
} }
if (devBean.getCategory() != null && !devBean.getCategory().isEmpty()) { if (devBean.getCategory() != null && !devBean.getCategory().isEmpty()) {
td.setCategory(devBean.getCategory()); td.setCategory(devBean.getCategory());
@ -622,11 +622,49 @@ public class DevicesFragment extends Fragment {
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
} }
// 合并后端最新 DP:后端是状态汇聚点(APP 回传 / 面板·中控屏上报 / 涂鸦云订阅),
// 涂鸦 SDK 数据层在 Mesh 本地控制、子设备无云端推送时不会更新
mergeBackendDps(bd, td);
result.add(td); result.add(td);
} }
return result; return result;
} }
/** 合并后端 DP 值到设备状态(后端优先,code 归一化为 dpId) */
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 */
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 =
com.xhai.iot.service.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;
}
private void showLoading(boolean show) { private void showLoading(boolean show) {
progressBar.setVisibility(show ? View.VISIBLE : View.GONE); progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
} }

View File

@ -695,17 +695,23 @@ public class HomeFragment extends Fragment {
/** 判断设备开关 DP 是否为开 */ /** 判断设备开关 DP 是否为开 */
private boolean isDeviceOn(TuyaDevice d) { private boolean isDeviceOn(TuyaDevice d) {
try { try {
// 多路插座/排插:任一路开即算开
TuyaSdkService.SwitchState state =
TuyaSdkService.readSwitchState(d.getId(), d.getDps());
if (state.isKnown()) {
return state.isAnyOn();
}
String dpId = TuyaSdkService.findSwitchDpId(d.getId()); String dpId = TuyaSdkService.findSwitchDpId(d.getId());
if (dpId == null) return false; if (dpId == null || d.getDps() == null) return false;
Object v = d.getDps().get(dpId);
if (v == null) {
Map<String, Object> probe = new HashMap<>(); Map<String, Object> probe = new HashMap<>();
probe.put(dpId, true); probe.put(dpId, true);
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(d.getId(), probe); Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(d.getId(), probe);
if (codeMap == null || d.getDps() == null) return false; if (codeMap != null && !codeMap.isEmpty()) {
Object v = null;
if (!codeMap.isEmpty()) {
v = d.getDps().get(codeMap.keySet().iterator().next()); v = d.getDps().get(codeMap.keySet().iterator().next());
} }
if (v == null) v = d.getDps().get(dpId); }
return Boolean.TRUE.equals(v); return Boolean.TRUE.equals(v);
} catch (Throwable e) { } catch (Throwable e) {
return false; return false;

View File

@ -103,24 +103,18 @@ public class DeviceGridAdapter extends RecyclerView.Adapter<DeviceGridAdapter.Vi
} }
// 判断设备是否有可写的布尔(开关)DP;无开关功能的设备(如蓝牙网关)不显示开关 // 判断设备是否有可写的布尔(开关)DP;无开关功能的设备(如蓝牙网关)不显示开关
final String switchDpId = TuyaSdkService.findSwitchDpId(device.getId()); // 多路设备(两位插座/四位排插):取全部"电源开关"通道,
// 把数字 id 转换为涂鸦 code(dps map key 是 code),用于读真实开关状态 // 场景、背光、指示灯等非电源开关已在 SDK 层按名称/分数排除
String switchDpCode = null; final List<TuyaSdkService.SwitchChannel> switchChannels =
if (switchDpId != null) { TuyaSdkService.findSwitchChannels(device.getId());
Map<String, Object> probe = new HashMap<>(); final String switchDpId = switchChannels.isEmpty() ? null : switchChannels.get(0).dpId;
probe.put(switchDpId, true); // 聚合状态:任一路开 → 视为已打开
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(device.getId(), probe); TuyaSdkService.SwitchState switchState =
if (codeMap != null && !codeMap.isEmpty()) { TuyaSdkService.readSwitchState(device.getId(), device.getDps());
switchDpCode = codeMap.keySet().iterator().next(); final boolean initialChecked = switchState.isKnown()
} ? switchState.isAnyOn()
} : Boolean.TRUE.equals(device.getDps() == null ? null
// 从 dps 读取设备真实开关状态(同步显示) : device.getDps().get(switchDpId));
Object dpValue = null;
if (switchDpCode != null && device.getDps() != null) {
dpValue = device.getDps().get(switchDpCode);
if (dpValue == null) dpValue = device.getDps().get(switchDpId);
}
final boolean initialChecked = Boolean.TRUE.equals(dpValue);
if (switchDpId == null || !online) { if (switchDpId == null || !online) {
// 无开关功能或离线:隐藏/禁用开关,避免网关等设备出现无效开关 // 无开关功能或离线:隐藏/禁用开关,避免网关等设备出现无效开关
@ -143,7 +137,13 @@ public class DeviceGridAdapter extends RecyclerView.Adapter<DeviceGridAdapter.Vi
"switch clicked: device=" + device.getId() "switch clicked: device=" + device.getId()
+ " checked=" + isChecked + " dpId=" + switchDpId); + " checked=" + isChecked + " dpId=" + switchDpId);
Map<String, Object> commands = new HashMap<>(); Map<String, Object> commands = new HashMap<>();
// 多路插座/排插:一键控制全部电源通道
for (TuyaSdkService.SwitchChannel ch : switchChannels) {
commands.put(ch.dpId, isChecked);
}
if (commands.isEmpty() && switchDpId != null) {
commands.put(switchDpId, isChecked); commands.put(switchDpId, isChecked);
}
PlatformServiceManager.getInstance(holder.itemView.getContext()) PlatformServiceManager.getInstance(holder.itemView.getContext())
.controlDevice(asPlatformDevice(device), commands, .controlDevice(asPlatformDevice(device), commands,
new IPlatformService.ControlCallback() { new IPlatformService.ControlCallback() {

View File

@ -124,20 +124,21 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
pendingToggleAt.remove(d.getId()); pendingToggleAt.remove(d.getId());
continue; continue;
} }
String switchDpId = TuyaSdkService.findSwitchDpId(d.getId()); // 多路开关:把所有电源通道都写成乐观值,保证卡片显示与点击一致
if (switchDpId == null) continue; List<TuyaSdkService.SwitchChannel> channels =
TuyaSdkService.findSwitchChannels(d.getId());
if (channels.isEmpty()) continue;
Map<String, Object> dps = d.getDps(); Map<String, Object> dps = d.getDps();
if (dps == null) { if (dps == null) {
dps = new HashMap<>(); dps = new HashMap<>();
d.setDps(dps); d.setDps(dps);
} }
Map<String, Object> probe = new HashMap<>(); for (TuyaSdkService.SwitchChannel ch : channels) {
probe.put(switchDpId, true); if (ch.code != null) {
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(d.getId(), probe); dps.put(ch.code, pending);
if (codeMap != null && !codeMap.isEmpty()) { }
dps.put(codeMap.keySet().iterator().next(), pending); dps.put(ch.dpId, pending);
} }
dps.put(switchDpId, pending);
} }
} }
@ -170,28 +171,25 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
} }
holder.ivIcon.setAlpha(online ? 1f : 0.45f); holder.ivIcon.setAlpha(online ? 1f : 0.45f);
// 开关 DP 逻辑(与 DeviceGridAdapter 一致) // 开关 DP 逻辑(支持多路:两位插座 / 四位排插;
final String switchDpId = TuyaSdkService.findSwitchDpId(device.getId()); // 场景、背光、指示灯等非电源开关已在 SDK 层按名称/分数排除)
String switchDpCode = null; final List<TuyaSdkService.SwitchChannel> channels =
if (switchDpId != null) { TuyaSdkService.findSwitchChannels(device.getId());
Map<String, Object> probe = new HashMap<>(); final boolean multiChannel = channels.size() > 1;
probe.put(switchDpId, true); final String switchDpId = channels.isEmpty() ? null : channels.get(0).dpId;
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(device.getId(), probe);
if (codeMap != null && !codeMap.isEmpty()) { // 多路开关聚合状态:任一路开 → 已打开;部分开 → 部分开启;全关 → 已关闭
switchDpCode = codeMap.keySet().iterator().next(); TuyaSdkService.SwitchState switchState =
} TuyaSdkService.readSwitchState(device.getId(), device.getDps());
} Boolean dpValue = null;
Object dpValue = null; if (switchState.isKnown()) {
if (switchDpCode != null && device.getDps() != null) { dpValue = switchState.isAnyOn();
dpValue = device.getDps().get(switchDpCode); } else if (device.getDps() != null) {
if (dpValue == null) dpValue = device.getDps().get(switchDpId);
}
// 无涂鸦 schema 的设备(平台虚拟/本地设备):从 dps 中兜底取常见开关字段 // 无涂鸦 schema 的设备(平台虚拟/本地设备):从 dps 中兜底取常见开关字段
if (dpValue == null && device.getDps() != null) {
for (String key : new String[]{"switch_1", "switch", "switch1", "power", "1"}) { for (String key : new String[]{"switch_1", "switch", "switch1", "power", "1"}) {
Object v = device.getDps().get(key); Object v = device.getDps().get(key);
if (v instanceof Boolean) { if (v instanceof Boolean) {
dpValue = v; dpValue = (Boolean) v;
break; break;
} }
} }
@ -204,6 +202,9 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
// 刚点击且控制结果未回传时保持乐观值,避免被列表里的旧 dps 刷回造成胶囊回弹闪动 // 刚点击且控制结果未回传时保持乐观值,避免被列表里的旧 dps 刷回造成胶囊回弹闪动
final boolean shownOn = resolveShownState(device.getId(), isOn); final boolean shownOn = resolveShownState(device.getId(), isOn);
// 多路设备部分开启时,文案区别于"已打开"
final boolean shownPartial = multiChannel && shownOn
&& !switchState.isAllOn() && !isPendingToggle(device.getId());
holder.pill.setAccentColor(accentColor); holder.pill.setAccentColor(accentColor);
if (switchDpId == null) { if (switchDpId == null) {
@ -225,8 +226,8 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
holder.pill.setEnabled(true); holder.pill.setEnabled(true);
// 列表绑定/刷新不走动画(否则每次刷新胶囊都会动一下);动画只由用户点击触发 // 列表绑定/刷新不走动画(否则每次刷新胶囊都会动一下);动画只由用户点击触发
holder.pill.setChecked(shownOn, false); holder.pill.setChecked(shownOn, false);
// 在线开关类:在线(主题色)+ 已打开/已关闭 // 在线开关类:在线(主题色)+ 已打开/已关闭/部分开启
holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, shownOn)); holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, shownOn, shownPartial));
holder.pill.setOnUserToggleListener(checked -> { holder.pill.setOnUserToggleListener(checked -> {
// 锁定乐观值:下发结果回传前,忽略列表刷新带回来的旧状态 // 锁定乐观值:下发结果回传前,忽略列表刷新带回来的旧状态
lockPendingToggle(device.getId(), checked); lockPendingToggle(device.getId(), checked);
@ -235,7 +236,13 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
controlStartListener.onControlStart(device.getId()); controlStartListener.onControlStart(device.getId());
} }
Map<String, Object> commands = new HashMap<>(); Map<String, Object> commands = new HashMap<>();
// 多路插座/排插:一键控制全部电源通道(场景、背光等非电源开关不参与)
for (TuyaSdkService.SwitchChannel ch : channels) {
commands.put(ch.dpId, checked);
}
if (commands.isEmpty() && switchDpId != null) {
commands.put(switchDpId, checked); commands.put(switchDpId, checked);
}
PlatformServiceManager.getInstance(holder.itemView.getContext()) PlatformServiceManager.getInstance(holder.itemView.getContext())
.controlDevice(asPlatformDevice(device), commands, .controlDevice(asPlatformDevice(device), commands,
new IPlatformService.ControlCallback() { new IPlatformService.ControlCallback() {
@ -253,7 +260,7 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
android.widget.Toast.LENGTH_SHORT).show(); android.widget.Toast.LENGTH_SHORT).show();
} }
}); });
holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, checked)); holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, checked, false));
}); });
} }
@ -318,6 +325,16 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
*/ */
private CharSequence buildStatusText(View anchor, boolean online, private CharSequence buildStatusText(View anchor, boolean online,
boolean switchLike, boolean isOn) { boolean switchLike, boolean isOn) {
return buildStatusText(anchor, online, switchLike, isOn, false);
}
/**
* 状态文案
*
* @param partial 多路设备部分开启(与"已打开"区分)
*/
private CharSequence buildStatusText(View anchor, boolean online,
boolean switchLike, boolean isOn, boolean partial) {
android.content.Context ctx = anchor.getContext(); android.content.Context ctx = anchor.getContext();
int muted = ctx.getColor(R.color.c_muted_fg); int muted = ctx.getColor(R.color.c_muted_fg);
if (!online) { if (!online) {
@ -332,17 +349,23 @@ public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.Vi
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return sp; return sp;
} }
String full = "在线 · " + (isOn ? "已打开" : "已关闭"); String full = "在线 · " + (partial ? "部分开启" : (isOn ? "已打开" : "已关闭"));
SpannableString sp = new SpannableString(full); SpannableString sp = new SpannableString(full);
// 「在线」用主题色 // 「在线」用主题色
sp.setSpan(new ForegroundColorSpan(accentColor), 0, 2, sp.setSpan(new ForegroundColorSpan(accentColor), 0, 2,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
// 「已打开」用在线绿 / 「已关闭」用灰色 // 「已打开/部分开启」用在线绿 / 「已关闭」用灰色
sp.setSpan(new ForegroundColorSpan(isOn ? ctx.getColor(R.color.online) : muted), sp.setSpan(new ForegroundColorSpan(isOn ? ctx.getColor(R.color.online) : muted),
3, full.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 3, full.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return sp; return sp;
} }
/** 该设备是否处于"控制结果未回传"的乐观值窗口内 */
private boolean isPendingToggle(String deviceId) {
Long at = pendingToggleAt.get(deviceId);
return at != null && System.currentTimeMillis() - at <= TOGGLE_LOCK_MS;
}
/** /**
* 设备类型是否为「开关类」(可开/关的设备:灯、开关、插座、排插等) * 设备类型是否为「开关类」(可开/关的设备:灯、开关、插座、排插等)
* 兼容本平台 device_type(light/socket/switch)与涂鸦 category(kg/cjkg/tdq/tgq...) * 兼容本平台 device_type(light/socket/switch)与涂鸦 category(kg/cjkg/tdq/tgq...)