diff --git a/Android/app/src/main/java/com/xhai/iot/model/ControlSchema.java b/Android/app/src/main/java/com/xhai/iot/model/ControlSchema.java index 94a6e7b..e02bc9c 100644 --- a/Android/app/src/main/java/com/xhai/iot/model/ControlSchema.java +++ b/Android/app/src/main/java/com/xhai/iot/model/ControlSchema.java @@ -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 enumLabels; + + /** + * 下发用的 key。 + * + *

语义显式化:原先只有隐式的 {@code key()}(dpId 优先), + * 而场景规则下发用的是 code —— 两条链路口径不一致,极易用错。 + * 现拆成两个显式方法,由调用方按目标传输层选择:

+ * + */ + 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); + } } } 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 1120f90..9675268 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 @@ -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 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 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 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 cmd = new HashMap<>(); - cmd.put(it.key(), e); + cmd.put(it.dpKey(), e); sendCommand(cmd); }); rg.addView(rb); diff --git a/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java b/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java index e45e0db..764ffb4 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java @@ -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 -> 通道列表。 + *

通道属静态数据(物模型变更才会变),故只拉取一次,避免每次刷新都发 N 个请求。

+ */ + private final Map> specChannelCache = new HashMap<>(); + private boolean specChannelsLoaded = false; + + /** + * 拉取后端产品物模型,为每台设备解析开关通道组并回填 adapter。 + * + *

物模型(grp=switch)由后端 SpecSemanticResolver 落库,是开关位识别的唯一权威; + * 拉取失败或物模型不可用时,adapter 自动回退本地打分逻辑,不影响可用性。

+ */ + private void loadSpecChannels(final List 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>() { + @Override + public void onResponse(retrofit2.Call> call, + retrofit2.Response> response) { + try { + ControlSchema cs = (response.body() != null) ? response.body().getData() : null; + List 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> call, Throwable t) { + if (--pending[0] <= 0) applySpecChannels(); + } + }); + } + } + + /** 把物模型通道回填给 adapter(局部刷新,避免整表重绘闪烁) */ + private void applySpecChannels() { + if (!isAdded() || adapter == null) return; + adapter.setSpecChannels(specChannelCache); + } + /** * 涂鸦侧设备已存在、但本平台 device 表缺失时,增量写回后端后刷新列表。 * 典型场景:蓝牙配网成功但没落库、或在涂鸦官方 App 里新增的设备。 diff --git a/Android/app/src/main/java/com/xhai/iot/ui/SceneDetailActivity.java b/Android/app/src/main/java/com/xhai/iot/ui/SceneDetailActivity.java index 119a9e0..357331d 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/SceneDetailActivity.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/SceneDetailActivity.java @@ -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()); } } } diff --git a/Android/app/src/main/java/com/xhai/iot/ui/adapter/DeviceRowAdapter.java b/Android/app/src/main/java/com/xhai/iot/ui/adapter/DeviceRowAdapter.java index 80dea8a..67b4428 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/adapter/DeviceRowAdapter.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/adapter/DeviceRowAdapter.java @@ -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 channels = - TuyaSdkService.findSwitchChannels(device.getId()); + // 通道来源优先级:后端物模型(grp=switch) > 本地打分兜底 + // 物模型由后端 SpecSemanticResolver 落库,是唯一权威;本地打分仅在 + // 物模型不可用(设备无产品/后端未同步/网络失败)时兜底。 + final List 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 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 通道列表。 + *

由外部(DevicesFragment)异步拉取后端 control-schema 并回填; + * 未回填或回填为空时自动回退本地打分逻辑。

+ */ + private final Map> specChannels = new ConcurrentHashMap<>(); + + /** + * 设置物模型通道缓存并局部刷新受影响设备。 + */ + public void setSpecChannels(Map> map) { + specChannels.clear(); + if (map != null) { + for (Map.Entry> e : map.entrySet()) { + if (e.getValue() != null && !e.getValue().isEmpty()) { + specChannels.put(e.getKey(), e.getValue()); + } + } + } + // 只刷新有开关的设备,避免整表重绘引起闪烁 + for (Map.Entry> e : specChannels.entrySet()) { + refreshDevice(e.getKey()); + } + } + + /** + * 解析设备的开关通道列表(物模型优先,本地打分兜底)。 + */ + private List resolveChannels(TuyaDevice device) { + List spec = specChannels.get(device.getId()); + if (spec != null && !spec.isEmpty()) { + List list = new ArrayList<>(spec.size()); + for (SpecSwitchResolver.Channel c : spec) { + list.add(new SwChannel(c.dpId, c.code, c.name)); + } + return list; + } + List local = TuyaSdkService.findSwitchChannels(device.getId()); + List 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; diff --git a/Android/app/src/main/java/com/xhai/iot/util/SpecSwitchResolver.java b/Android/app/src/main/java/com/xhai/iot/util/SpecSwitchResolver.java new file mode 100644 index 0000000..caa5c7d --- /dev/null +++ b/Android/app/src/main/java/com/xhai/iot/util/SpecSwitchResolver.java @@ -0,0 +1,108 @@ +package com.xhai.iot.util; + +import com.xhai.iot.model.ControlSchema; + +import java.util.ArrayList; +import java.util.List; + +/** + * 物模型驱动的开关通道解析器。 + * + *

背景:设备列表页的胶囊开关此前依赖客户端本地打分 + * ({@code TuyaSdkService.switchDpScore},靠 26 个中英排除词猜哪个 DP 是主开关), + * 这套启发式与后端 {@code SpecSemanticResolver} 是两套口径,必然漂移 —— + * JD2BTSA「背光开关被当成主开关误控」即由此而来。

+ * + *

本类以后端物模型为唯一权威:

+ *
    + *
  • 通道来源 = control-schema 中 grp=switch 的功能点(即物模型里的 switch 组);
  • + *
  • 通道数量 = 该组功能点个数,列表页据此显示「x/y 开」;
  • + *
  • 整组联动 = 一次下发把该组所有通道一起置为同一状态。
  • + *
+ * + *

物模型不可用(设备无产品 / 后端未同步 / 网络失败)时返回 {@code null}, + * 由调用方回退到本地打分逻辑 —— 保证离线与新设备场景不降级。

+ */ +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 resolve(ControlSchema schema) { + if (schema == null || !schema.available || schema.items == null || schema.items.isEmpty()) { + return null; + } + List 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; + } + + /** + * 判定该通道集合是否支持「整组联动」。 + * + *

多通道设备(如 JD6BTSA 的 switch_1~4)整组一起开关; + * 单通道设备与整组联动等价。

+ */ + public static boolean supportsGroupToggle(List channels) { + return channels != null && !channels.isEmpty(); + } +} \ No newline at end of file