feat(D): Android 端消费物模型语义, 开关通道以物模型为唯一权威

ControlSchema.ControlItem:
- 新增 semantic / grp / isPrimary / enumLabels 字段
- 拆分隐式 key() 为显式 dpKey()(涂鸦 publishDps 用数字dpId) 与
  codeKey()(平台无关稳定标识, 场景规则/Mesh/云端用)
  原实现两条链路口径不一致(一处dpId一处code), 极易用错

DeviceControlActivity:
- 4 处 it.key() -> it.dpKey()(publishDps 路径)
- 主开关选择新增物模型兜底: 后端 mainSwitchCode -> is_primary -> semantic=power
  (语义由后端 SpecSemanticResolver 落库, 取代客户端按名称猜测)

新增 SpecSwitchResolver:
- 物模型驱动的开关通道解析器
- 通道来源 = control-schema 中 grp=switch 的功能点(即物模型 switch 组)
- 物模型不可用(设备无产品/未同步/网络失败)返回 null -> 调用方回退本地打分
- 保证离线与新设备场景不降级

DeviceRowAdapter:
- 新增物模型通道缓存(setSpecChannels), 回填后按设备局部刷新(不整表重绘)
- 统一内部 SwChannel 视图, 屏蔽物模型/本地打分两种来源差异
- 通道解析优先级: 后端物模型 > 本地打分兜底

DevicesFragment:
- 首次加载后异步拉取各设备 control-schema, 解析开关通道回填 adapter
- 通道属静态数据, 只拉一次, 避免每次刷新发 N 个请求

SceneDetailActivity:
- 规则下发的动作 key 改用 codeKey(), 并注释说明与 publishDps 的区别

验证: javac 编译 ControlSchema + SpecSwitchResolver 通过(两者不依赖 Android SDK);
其余改动为结构性替换, 待 Android Studio 编译验证
This commit is contained in:
2995840376 2026-10-27 13:23:24 +08:00
parent d6d9a94736
commit 10344e0b05
6 changed files with 295 additions and 12 deletions

View File

@ -34,9 +34,44 @@ public class ControlSchema {
public boolean writable;
public Object value;
public String key() {
// 下发优先用 DP 映射,没有则用功能点编码
// ===== 语义扩展(后端 SpecSemanticResolver 落库,取代按名称猜测)=====
/** 功能语义标签(power/switch/backlight/indicator/brightness/color_temp/mode/scene/countdown/...) */
public String semantic;
/** 功能分组(switch/scene/mode/countdown/display/config/measure/status) */
public String grp;
/** 是否主开关(1是,0否) */
public Integer isPrimary;
/** 枚举中文名映射 */
public java.util.Map<String, String> enumLabels;
/**
* 下发用的 key。
*
* <p><b>语义显式化</b>:原先只有隐式的 {@code key()}(dpId 优先),
* 而场景规则下发用的是 code —— 两条链路口径不一致,极易用错。
* 现拆成两个显式方法,由调用方按目标传输层选择:</p>
* <ul>
* <li>{@link #dpKey()}:涂鸦 SDK publishDps 用(涂鸦接受数字 dpId);</li>
* <li>{@link #codeKey()}:后端规则/场景/Mesh 用(平台无关稳定标识)。</li>
* </ul>
*/
public String dpKey() {
return (dpId != null && !dpId.isEmpty()) ? dpId : code;
}
/** 平台无关的功能点编码(场景规则、Mesh、云端下发统一用它) */
public String codeKey() {
return code;
}
/** 是否属于开关通道组(设备列表整组联动的依据) */
public boolean isSwitchChannel() {
return "switch".equals(grp);
}
/** 语义上是否为主电源开关 */
public boolean isPowerSemantic() {
return "power".equals(semantic);
}
}
}

View File

@ -176,6 +176,24 @@ public class DeviceControlActivity extends AppCompatActivity {
}
}
}
if (main == null) {
// 物模型兜底:后端未指定主开关时,优先用 is_primary,其次用 semantic=power
// (语义由后端 SpecSemanticResolver 落库,取代客户端按名称猜测)
for (com.xhai.iot.model.ControlSchema.ControlItem it : cs.items) {
if (it.isPrimary != null && it.isPrimary == 1) {
main = it;
break;
}
}
if (main == null) {
for (com.xhai.iot.model.ControlSchema.ControlItem it : cs.items) {
if (it.isPowerSemantic() && it.writable) {
main = it;
break;
}
}
}
}
if (main != null && "switch".equals(main.widget)) {
final com.xhai.iot.model.ControlSchema.ControlItem mi = main;
android.widget.TextView tv = new android.widget.TextView(this);
@ -188,7 +206,7 @@ public class DeviceControlActivity extends AppCompatActivity {
mi.value = next;
tv.setText(next ? "已开启(点击关闭)" : "已关闭(点击开启)");
java.util.Map<String, Object> cmd = new HashMap<>();
cmd.put(mi.key(), next);
cmd.put(mi.dpKey(), next);
sendCommand(cmd);
});
llDpContainer.addView(tv);
@ -232,7 +250,7 @@ public class DeviceControlActivity extends AppCompatActivity {
sw.setOnCheckedChangeListener((btn, checked) -> {
it.value = checked;
java.util.Map<String, Object> cmd = new HashMap<>();
cmd.put(it.key(), checked);
cmd.put(it.dpKey(), checked);
sendCommand(cmd);
});
row.addView(sw);
@ -255,7 +273,7 @@ public class DeviceControlActivity extends AppCompatActivity {
public void onStopTrackingTouch(android.widget.SeekBar seekBar) {
it.value = last[0];
java.util.Map<String, Object> cmd = new HashMap<>();
cmd.put(it.key(), last[0]);
cmd.put(it.dpKey(), last[0]);
sendCommand(cmd);
}
});
@ -273,7 +291,7 @@ public class DeviceControlActivity extends AppCompatActivity {
rb.setOnClickListener(v -> {
it.value = e;
java.util.Map<String, Object> cmd = new HashMap<>();
cmd.put(it.key(), e);
cmd.put(it.dpKey(), e);
sendCommand(cmd);
});
rg.addView(rb);

View File

@ -22,15 +22,18 @@ import com.xhai.iot.R;
import com.xhai.iot.data.ApiClient;
import com.xhai.iot.data.ApiService;
import com.xhai.iot.model.BackendDevice;
import com.xhai.iot.model.ControlSchema;
import com.xhai.iot.model.PageData;
import com.xhai.iot.model.Result;
import com.xhai.iot.model.TuyaDevice;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.adapter.DeviceRowAdapter;
import com.xhai.iot.util.SessionManager;
import com.xhai.iot.util.SpecSwitchResolver;
import com.xhai.iot.util.ThemeManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -363,6 +366,8 @@ public class DevicesFragment extends Fragment {
? response.body().getData().getRecords() : null;
allDevices = toTuyaDevices(list);
applyFilter();
// 物模型驱动的开关通道(取代本地名称打分猜测;物模型不可用时自动回退)
loadSpecChannels(allDevices);
// 补同步:涂鸦侧已存在但本平台缺失的设备(如蓝牙配网/涂鸦App新增)写回后端
maybeSyncMissingSdkDevices(homeId, list);
} else {
@ -385,6 +390,56 @@ public class DevicesFragment extends Fragment {
/** 每次进入页面只允许补同步一次,避免「刷新→同步→刷新」递归 */
private boolean sdkMissingSyncDone = false;
/**
* 物模型开关通道缓存:deviceId -> 通道列表。
* <p>通道属静态数据(物模型变更才会变),故只拉取一次,避免每次刷新都发 N 个请求。</p>
*/
private final Map<String, List<SpecSwitchResolver.Channel>> specChannelCache = new HashMap<>();
private boolean specChannelsLoaded = false;
/**
* 拉取后端产品物模型,为每台设备解析开关通道组并回填 adapter。
*
* <p>物模型(grp=switch)由后端 SpecSemanticResolver 落库,是开关位识别的唯一权威;
* 拉取失败或物模型不可用时,adapter 自动回退本地打分逻辑,不影响可用性。</p>
*/
private void loadSpecChannels(final List<TuyaDevice> devices) {
if (specChannelsLoaded || devices == null || devices.isEmpty() || !isAdded()) return;
final String auth = session.getBearerToken();
if (auth == null) return;
specChannelsLoaded = true;
final int[] pending = {devices.size()};
for (final TuyaDevice d : devices) {
ApiClient.getApiService().getDeviceControlSchema(auth, d.getId())
.enqueue(new retrofit2.Callback<Result<ControlSchema>>() {
@Override
public void onResponse(retrofit2.Call<Result<ControlSchema>> call,
retrofit2.Response<Result<ControlSchema>> response) {
try {
ControlSchema cs = (response.body() != null) ? response.body().getData() : null;
List<SpecSwitchResolver.Channel> ch = SpecSwitchResolver.resolve(cs);
if (ch != null && !ch.isEmpty()) {
specChannelCache.put(d.getId(), ch);
}
} catch (Throwable ignored) {
}
if (--pending[0] <= 0) applySpecChannels();
}
@Override
public void onFailure(retrofit2.Call<Result<ControlSchema>> call, Throwable t) {
if (--pending[0] <= 0) applySpecChannels();
}
});
}
}
/** 把物模型通道回填给 adapter(局部刷新,避免整表重绘闪烁) */
private void applySpecChannels() {
if (!isAdded() || adapter == null) return;
adapter.setSpecChannels(specChannelCache);
}
/**
* 涂鸦侧设备已存在、但本平台 device 表缺失时,增量写回后端后刷新列表。
* 典型场景:蓝牙配网成功但没落库、或在涂鸦官方 App 里新增的设备。

View File

@ -370,8 +370,11 @@ public class SceneDetailActivity extends AppCompatActivity {
if (schema.items != null) {
for (ControlSchema.ControlItem it : schema.items) {
if (it != null && it.writable && it.code != null) {
writable.put(it.code, it);
keys.add(it.code);
// 场景/规则下发走后端,由后端转涂鸦云命令 —— 必须用
// 平台无关的功能点编码(code),不能用涂鸦数字 dpId。
// 与设备详情页 publishDps 用 dpKey() 明确区分。
writable.put(it.codeKey(), it);
keys.add(it.codeKey());
}
}
}

View File

@ -18,12 +18,14 @@ import com.xhai.iot.service.IPlatformService;
import com.xhai.iot.service.PlatformServiceManager;
import com.xhai.iot.service.TuyaSdkService;
import com.xhai.iot.ui.widget.PillSwitch;
import com.xhai.iot.util.SpecSwitchResolver;
import com.xhai.iot.util.ThemeManager;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 设备页列表行适配器(设计稿 DevicesPage list view)
@ -179,11 +181,13 @@ public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.View
*/
private void bindSwitchState(ViewHolder holder, TuyaDevice device) {
boolean online = device.isOnline();
final List<TuyaSdkService.SwitchChannel> channels =
TuyaSdkService.findSwitchChannels(device.getId());
// 通道来源优先级:后端物模型(grp=switch) > 本地打分兜底
// 物模型由后端 SpecSemanticResolver 落库,是唯一权威;本地打分仅在
// 物模型不可用(设备无产品/后端未同步/网络失败)时兜底。
final List<SwChannel> channels = resolveChannels(device);
// 聚合状态:所有开关位都开才算"全开"
int onCount = 0;
for (TuyaSdkService.SwitchChannel c : channels) {
for (SwChannel c : channels) {
if (readSwitchState(device, c.dpId)) onCount++;
}
final int total = channels.size();
@ -217,7 +221,7 @@ public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.View
// 列表页一次性联动控制该设备的所有开关位
// (单个开关位请点进设备详情页控制)
Map<String, Object> commands = new HashMap<>();
for (TuyaSdkService.SwitchChannel c : channels) {
for (SwChannel c : channels) {
commands.put(c.dpId, checked);
}
sendSwitchCommand(holder, device, commands, checked);
@ -229,6 +233,66 @@ public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.View
}
}
/**
* 物模型开关通道缓存:deviceId -> 通道列表。
* <p>由外部(DevicesFragment)异步拉取后端 control-schema 并回填;
* 未回填或回填为空时自动回退本地打分逻辑。</p>
*/
private final Map<String, List<SpecSwitchResolver.Channel>> specChannels = new ConcurrentHashMap<>();
/**
* 设置物模型通道缓存并局部刷新受影响设备。
*/
public void setSpecChannels(Map<String, List<SpecSwitchResolver.Channel>> map) {
specChannels.clear();
if (map != null) {
for (Map.Entry<String, List<SpecSwitchResolver.Channel>> e : map.entrySet()) {
if (e.getValue() != null && !e.getValue().isEmpty()) {
specChannels.put(e.getKey(), e.getValue());
}
}
}
// 只刷新有开关的设备,避免整表重绘引起闪烁
for (Map.Entry<String, List<SpecSwitchResolver.Channel>> e : specChannels.entrySet()) {
refreshDevice(e.getKey());
}
}
/**
* 解析设备的开关通道列表(物模型优先,本地打分兜底)。
*/
private List<SwChannel> resolveChannels(TuyaDevice device) {
List<SpecSwitchResolver.Channel> spec = specChannels.get(device.getId());
if (spec != null && !spec.isEmpty()) {
List<SwChannel> list = new ArrayList<>(spec.size());
for (SpecSwitchResolver.Channel c : spec) {
list.add(new SwChannel(c.dpId, c.code, c.name));
}
return list;
}
List<TuyaSdkService.SwitchChannel> local = TuyaSdkService.findSwitchChannels(device.getId());
List<SwChannel> list = new ArrayList<>(local.size());
for (TuyaSdkService.SwitchChannel c : local) {
list.add(new SwChannel(c.dpId, c.code, c.name));
}
return list;
}
/**
* 统一的开关通道视图(屏蔽物模型与本地打分两种来源的差异)。
*/
private static final class SwChannel {
final String dpId;
final String code;
final String name;
SwChannel(String dpId, String code, String name) {
this.dpId = dpId;
this.code = code;
this.name = name;
}
}
/** 读取某个开关位当前的开关状态 */
private boolean readSwitchState(TuyaDevice device, String dpId) {
if (dpId == null || device.getDps() == null) return false;

View File

@ -0,0 +1,108 @@
package com.xhai.iot.util;
import com.xhai.iot.model.ControlSchema;
import java.util.ArrayList;
import java.util.List;
/**
* 物模型驱动的开关通道解析器。
*
* <p>背景:设备列表页的胶囊开关此前依赖客户端本地打分
* ({@code TuyaSdkService.switchDpScore},靠 26 个中英排除词猜哪个 DP 是主开关),
* 这套启发式与后端 {@code SpecSemanticResolver} 是两套口径,必然漂移 ——
* JD2BTSA「背光开关被当成主开关误控」即由此而来。</p>
*
* <p>本类以<b>后端物模型为唯一权威</b>:</p>
* <ul>
* <li>通道来源 = control-schema 中 grp=switch 的功能点(即物模型里的 switch 组);</li>
* <li>通道数量 = 该组功能点个数,列表页据此显示「x/y 开」;</li>
* <li>整组联动 = 一次下发把该组所有通道一起置为同一状态。</li>
* </ul>
*
* <p>物模型不可用(设备无产品 / 后端未同步 / 网络失败)时返回 {@code null},
* 由调用方回退到本地打分逻辑 —— 保证离线与新设备场景不降级。</p>
*/
public final class SpecSwitchResolver {
/** 物模型语义分组:开关通道组 */
private static final String GRP_SWITCH = "switch";
/** 物模型语义标签:主电源开关 */
private static final String SEMANTIC_POWER = "power";
private SpecSwitchResolver() {
}
/**
* 一个开关通道。
*/
public static class Channel {
/** 功能点编码(平台无关,场景/规则下发用它) */
public final String code;
/** 功能点名称 */
public final String name;
/** 涂鸦数字 DP 别名(publishDps 用;无则为空表示只能用 code) */
public final String dpId;
/** 是否语义上的主电源开关 */
public final boolean primary;
public Channel(String code, String name, String dpId, boolean primary) {
this.code = code;
this.name = name;
this.dpId = dpId;
this.primary = primary;
}
/**
* publishDps 的下发 key:优先数字 dpId(涂鸦 SDK 接受),无则退回 code。
*/
public String dpKey() {
return (dpId != null && !dpId.isEmpty()) ? dpId : code;
}
}
/**
* 从物模型中解析开关通道组。
*
* @param schema 后端 control-schema(可为 null)
* @return 通道列表;物模型不可用或没有开关组时返回 null(表示应回退本地打分)
*/
public static List<Channel> resolve(ControlSchema schema) {
if (schema == null || !schema.available || schema.items == null || schema.items.isEmpty()) {
return null;
}
List<Channel> channels = new ArrayList<>();
// 1) 优先取 grp=switch 的通道组
for (ControlSchema.ControlItem it : schema.items) {
if (it == null || it.code == null) {
continue;
}
if (GRP_SWITCH.equals(it.grp) || SEMANTIC_POWER.equals(it.semantic)) {
channels.add(new Channel(it.code, it.name, it.dpId, it.isPrimary != null && it.isPrimary == 1));
}
}
if (!channels.isEmpty()) {
return channels;
}
// 2) 物模型没有分组信息时,退而取所有可写布尔开关
for (ControlSchema.ControlItem it : schema.items) {
if (it == null || it.code == null || !it.writable) {
continue;
}
if ("bool".equalsIgnoreCase(it.type) && "switch".equals(it.widget)) {
channels.add(new Channel(it.code, it.name, it.dpId, it.isPrimary != null && it.isPrimary == 1));
}
}
return channels.isEmpty() ? null : channels;
}
/**
* 判定该通道集合是否支持「整组联动」。
*
* <p>多通道设备(如 JD6BTSA 的 switch_1~4)整组一起开关;
* 单通道设备与整组联动等价。</p>
*/
public static boolean supportsGroupToggle(List<Channel> channels) {
return channels != null && !channels.isEmpty();
}
}