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:
parent
d6d9a94736
commit
10344e0b05
@ -34,9 +34,44 @@ public class ControlSchema {
|
|||||||
public boolean writable;
|
public boolean writable;
|
||||||
public Object value;
|
public Object value;
|
||||||
|
|
||||||
public String key() {
|
// ===== 语义扩展(后端 SpecSemanticResolver 落库,取代按名称猜测)=====
|
||||||
// 下发优先用 DP 映射,没有则用功能点编码
|
/** 功能语义标签(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;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)) {
|
if (main != null && "switch".equals(main.widget)) {
|
||||||
final com.xhai.iot.model.ControlSchema.ControlItem mi = main;
|
final com.xhai.iot.model.ControlSchema.ControlItem mi = main;
|
||||||
android.widget.TextView tv = new android.widget.TextView(this);
|
android.widget.TextView tv = new android.widget.TextView(this);
|
||||||
@ -188,7 +206,7 @@ public class DeviceControlActivity extends AppCompatActivity {
|
|||||||
mi.value = next;
|
mi.value = next;
|
||||||
tv.setText(next ? "已开启(点击关闭)" : "已关闭(点击开启)");
|
tv.setText(next ? "已开启(点击关闭)" : "已关闭(点击开启)");
|
||||||
java.util.Map<String, Object> cmd = new HashMap<>();
|
java.util.Map<String, Object> cmd = new HashMap<>();
|
||||||
cmd.put(mi.key(), next);
|
cmd.put(mi.dpKey(), next);
|
||||||
sendCommand(cmd);
|
sendCommand(cmd);
|
||||||
});
|
});
|
||||||
llDpContainer.addView(tv);
|
llDpContainer.addView(tv);
|
||||||
@ -232,7 +250,7 @@ public class DeviceControlActivity extends AppCompatActivity {
|
|||||||
sw.setOnCheckedChangeListener((btn, checked) -> {
|
sw.setOnCheckedChangeListener((btn, checked) -> {
|
||||||
it.value = checked;
|
it.value = checked;
|
||||||
java.util.Map<String, Object> cmd = new HashMap<>();
|
java.util.Map<String, Object> cmd = new HashMap<>();
|
||||||
cmd.put(it.key(), checked);
|
cmd.put(it.dpKey(), checked);
|
||||||
sendCommand(cmd);
|
sendCommand(cmd);
|
||||||
});
|
});
|
||||||
row.addView(sw);
|
row.addView(sw);
|
||||||
@ -255,7 +273,7 @@ public class DeviceControlActivity extends AppCompatActivity {
|
|||||||
public void onStopTrackingTouch(android.widget.SeekBar seekBar) {
|
public void onStopTrackingTouch(android.widget.SeekBar seekBar) {
|
||||||
it.value = last[0];
|
it.value = last[0];
|
||||||
java.util.Map<String, Object> cmd = new HashMap<>();
|
java.util.Map<String, Object> cmd = new HashMap<>();
|
||||||
cmd.put(it.key(), last[0]);
|
cmd.put(it.dpKey(), last[0]);
|
||||||
sendCommand(cmd);
|
sendCommand(cmd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -273,7 +291,7 @@ public class DeviceControlActivity extends AppCompatActivity {
|
|||||||
rb.setOnClickListener(v -> {
|
rb.setOnClickListener(v -> {
|
||||||
it.value = e;
|
it.value = e;
|
||||||
java.util.Map<String, Object> cmd = new HashMap<>();
|
java.util.Map<String, Object> cmd = new HashMap<>();
|
||||||
cmd.put(it.key(), e);
|
cmd.put(it.dpKey(), e);
|
||||||
sendCommand(cmd);
|
sendCommand(cmd);
|
||||||
});
|
});
|
||||||
rg.addView(rb);
|
rg.addView(rb);
|
||||||
|
|||||||
@ -22,15 +22,18 @@ import com.xhai.iot.R;
|
|||||||
import com.xhai.iot.data.ApiClient;
|
import com.xhai.iot.data.ApiClient;
|
||||||
import com.xhai.iot.data.ApiService;
|
import com.xhai.iot.data.ApiService;
|
||||||
import com.xhai.iot.model.BackendDevice;
|
import com.xhai.iot.model.BackendDevice;
|
||||||
|
import com.xhai.iot.model.ControlSchema;
|
||||||
import com.xhai.iot.model.PageData;
|
import com.xhai.iot.model.PageData;
|
||||||
import com.xhai.iot.model.Result;
|
import com.xhai.iot.model.Result;
|
||||||
import com.xhai.iot.model.TuyaDevice;
|
import com.xhai.iot.model.TuyaDevice;
|
||||||
import com.xhai.iot.service.TuyaSdkService;
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
import com.xhai.iot.ui.adapter.DeviceRowAdapter;
|
import com.xhai.iot.ui.adapter.DeviceRowAdapter;
|
||||||
import com.xhai.iot.util.SessionManager;
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
import com.xhai.iot.util.SpecSwitchResolver;
|
||||||
import com.xhai.iot.util.ThemeManager;
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -363,6 +366,8 @@ public class DevicesFragment extends Fragment {
|
|||||||
? response.body().getData().getRecords() : null;
|
? response.body().getData().getRecords() : null;
|
||||||
allDevices = toTuyaDevices(list);
|
allDevices = toTuyaDevices(list);
|
||||||
applyFilter();
|
applyFilter();
|
||||||
|
// 物模型驱动的开关通道(取代本地名称打分猜测;物模型不可用时自动回退)
|
||||||
|
loadSpecChannels(allDevices);
|
||||||
// 补同步:涂鸦侧已存在但本平台缺失的设备(如蓝牙配网/涂鸦App新增)写回后端
|
// 补同步:涂鸦侧已存在但本平台缺失的设备(如蓝牙配网/涂鸦App新增)写回后端
|
||||||
maybeSyncMissingSdkDevices(homeId, list);
|
maybeSyncMissingSdkDevices(homeId, list);
|
||||||
} else {
|
} else {
|
||||||
@ -385,6 +390,56 @@ public class DevicesFragment extends Fragment {
|
|||||||
/** 每次进入页面只允许补同步一次,避免「刷新→同步→刷新」递归 */
|
/** 每次进入页面只允许补同步一次,避免「刷新→同步→刷新」递归 */
|
||||||
private boolean sdkMissingSyncDone = false;
|
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 表缺失时,增量写回后端后刷新列表。
|
* 涂鸦侧设备已存在、但本平台 device 表缺失时,增量写回后端后刷新列表。
|
||||||
* 典型场景:蓝牙配网成功但没落库、或在涂鸦官方 App 里新增的设备。
|
* 典型场景:蓝牙配网成功但没落库、或在涂鸦官方 App 里新增的设备。
|
||||||
|
|||||||
@ -370,8 +370,11 @@ public class SceneDetailActivity extends AppCompatActivity {
|
|||||||
if (schema.items != null) {
|
if (schema.items != null) {
|
||||||
for (ControlSchema.ControlItem it : schema.items) {
|
for (ControlSchema.ControlItem it : schema.items) {
|
||||||
if (it != null && it.writable && it.code != null) {
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,12 +18,14 @@ import com.xhai.iot.service.IPlatformService;
|
|||||||
import com.xhai.iot.service.PlatformServiceManager;
|
import com.xhai.iot.service.PlatformServiceManager;
|
||||||
import com.xhai.iot.service.TuyaSdkService;
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
import com.xhai.iot.ui.widget.PillSwitch;
|
import com.xhai.iot.ui.widget.PillSwitch;
|
||||||
|
import com.xhai.iot.util.SpecSwitchResolver;
|
||||||
import com.xhai.iot.util.ThemeManager;
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设备页列表行适配器(设计稿 DevicesPage list view)
|
* 设备页列表行适配器(设计稿 DevicesPage list view)
|
||||||
@ -179,11 +181,13 @@ public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.View
|
|||||||
*/
|
*/
|
||||||
private void bindSwitchState(ViewHolder holder, TuyaDevice device) {
|
private void bindSwitchState(ViewHolder holder, TuyaDevice device) {
|
||||||
boolean online = device.isOnline();
|
boolean online = device.isOnline();
|
||||||
final List<TuyaSdkService.SwitchChannel> channels =
|
// 通道来源优先级:后端物模型(grp=switch) > 本地打分兜底
|
||||||
TuyaSdkService.findSwitchChannels(device.getId());
|
// 物模型由后端 SpecSemanticResolver 落库,是唯一权威;本地打分仅在
|
||||||
|
// 物模型不可用(设备无产品/后端未同步/网络失败)时兜底。
|
||||||
|
final List<SwChannel> channels = resolveChannels(device);
|
||||||
// 聚合状态:所有开关位都开才算"全开"
|
// 聚合状态:所有开关位都开才算"全开"
|
||||||
int onCount = 0;
|
int onCount = 0;
|
||||||
for (TuyaSdkService.SwitchChannel c : channels) {
|
for (SwChannel c : channels) {
|
||||||
if (readSwitchState(device, c.dpId)) onCount++;
|
if (readSwitchState(device, c.dpId)) onCount++;
|
||||||
}
|
}
|
||||||
final int total = channels.size();
|
final int total = channels.size();
|
||||||
@ -217,7 +221,7 @@ public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.View
|
|||||||
// 列表页一次性联动控制该设备的所有开关位
|
// 列表页一次性联动控制该设备的所有开关位
|
||||||
// (单个开关位请点进设备详情页控制)
|
// (单个开关位请点进设备详情页控制)
|
||||||
Map<String, Object> commands = new HashMap<>();
|
Map<String, Object> commands = new HashMap<>();
|
||||||
for (TuyaSdkService.SwitchChannel c : channels) {
|
for (SwChannel c : channels) {
|
||||||
commands.put(c.dpId, checked);
|
commands.put(c.dpId, checked);
|
||||||
}
|
}
|
||||||
sendSwitchCommand(holder, device, commands, 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) {
|
private boolean readSwitchState(TuyaDevice device, String dpId) {
|
||||||
if (dpId == null || device.getDps() == null) return false;
|
if (dpId == null || device.getDps() == null) return false;
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user