feat(B5-B7): 引入 SpecSemanticResolver 作为物模型语义唯一权威

新增 SpecSemanticResolver (356行):
- 语义标签体系: power/switch/backlight/indicator/brightness/color_temp/
  mode/scene/countdown/child_lock/temperature/humidity/battery/motion/
  contact/fault/raw
- 功能分组 grp: switch/scene/mode/countdown/display/config/measure/status
- resolve(): 单功能点语义推导(非主控词优先 -> 功能语义 -> 开关兜底)
- pickPrimary(): 产品范围内选主开关(power优先 -> switch组首个 -> 唯一可写bool兜底)
- isReadOnly(): 只读语义修正, 修复涂鸦 fnCodes 为空时兜底'全部可写'的缺陷

实体/DTO 补齐语义字段(V19 建了列但代码里没有):
- ProductProperty: semantic/grp/isPrimary/channel/completionMode/enumLabel
- ProductAction: completionMode
- ControlItem: semantic/grp/isPrimary/enumLabels

ControlSchemaServiceImpl 改造:
- toItem(): 搬运语义字段; 物模型无语义时即时推导(旧数据兼容);
  状态量被误标 write 时强制只读
- pickMainSwitch(): 优先 is_primary -> semantic=power -> 回退旧启发式
  (原实现按名称含 switch/power 猜测, 是 JD2BTSA 背光误控的根因)
- fillValues(): code 优先、dp_id 兜底(修复 dps key 语义分裂导致回填失败)

ProductServiceImpl.reconcileSpecFromTuya:
- 同步物模型时落库 semantic/grp, 并用语义修正 access
- 新增 applyPrimary(): 选主开关并回填 is_primary

验证: mvn -pl iot-common,iot-device -am compile 通过
This commit is contained in:
2995840376 2026-10-27 12:32:06 +08:00
parent 9786286ad3
commit 6461acecd1
6 changed files with 565 additions and 1 deletions

View File

@ -53,4 +53,18 @@ public class ControlItem {
@Schema(description = "当前值(按设备查询时填充)") @Schema(description = "当前值(按设备查询时填充)")
private Object value; private Object value;
// ===== 语义扩展(客户端据此渲染,取代按名称猜测)=====
@Schema(description = "功能语义标签(power/switch/backlight/indicator/brightness/color_temp/mode/scene/countdown/...)")
private String semantic;
@Schema(description = "功能分组(switch/scene/mode/countdown/display/config/measure/status)")
private String grp;
@Schema(description = "是否主开关(1是,0否)")
private Integer isPrimary;
@Schema(description = "枚举中文名映射")
private java.util.Map<String, String> enumLabels;
} }

View File

@ -29,4 +29,7 @@ public class ProductAction extends BaseEntity {
@Schema(description = "输出参数(JSON)") @Schema(description = "输出参数(JSON)")
private String output; private String output;
@Schema(description = "完成模式(sync 同步完成 / ack_only 仅受理)")
private String completionMode;
} }

View File

@ -53,4 +53,24 @@ public class ProductProperty extends BaseEntity {
@Schema(description = "排序") @Schema(description = "排序")
private Integer sort; private Integer sort;
// ===== 语义扩展(V19 建列 / V22 补列,此前库里有列但代码没用)=====
@Schema(description = "功能语义标签(power/switch/backlight/indicator/brightness/color_temp/mode/scene/countdown/child_lock/temperature/humidity/battery/motion/contact/fault/raw)")
private String semantic;
@Schema(description = "功能分组(switch/scene/mode/countdown/display/config/measure/status)")
private String grp;
@Schema(description = "是否主开关(1是,0否)")
private Integer isPrimary;
@Schema(description = "物理通道")
private String channel;
@Schema(description = "完成模式(sync/ack_only)")
private String completionMode;
@Schema(description = "枚举中文名(JSON对象)")
private String enumLabel;
} }

View File

@ -17,9 +17,11 @@ import com.iot.device.mapper.ProductEventMapper;
import com.iot.device.mapper.ProductMapper; import com.iot.device.mapper.ProductMapper;
import com.iot.device.mapper.ProductPropertyMapper; import com.iot.device.mapper.ProductPropertyMapper;
import com.iot.device.service.ControlSchemaService; import com.iot.device.service.ControlSchemaService;
import com.iot.device.service.spec.SpecSemanticResolver;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -42,6 +44,7 @@ public class ControlSchemaServiceImpl implements ControlSchemaService {
private final ProductEventMapper productEventMapper; private final ProductEventMapper productEventMapper;
private final DeviceMapper deviceMapper; private final DeviceMapper deviceMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SpecSemanticResolver semanticResolver;
@Override @Override
public ControlSchema getProductSchema(Long productId) { public ControlSchema getProductSchema(Long productId) {
@ -134,8 +137,19 @@ public class ControlSchemaServiceImpl implements ControlSchemaService {
return result; return result;
} }
/**
* 回填当前值。
*
* <p><b>key 语义统一</b>:物模型 code 是平台无关的稳定标识(涂鸦 code),
* 而 dp_id 是涂鸦的数字 DP 别名。运行期上报的 dps 的 key 两者都可能出现
* (APP 上报用数字 dpId,云端状态同步用 code),因此按 code 优先、dp_id 兜底匹配。</p>
*/
private void fillValues(List<ControlItem> items, Map<String, Object> dps) { private void fillValues(List<ControlItem> items, Map<String, Object> dps) {
for (ControlItem item : items) { for (ControlItem item : items) {
if (item.getCode() != null && dps.containsKey(item.getCode())) {
item.setValue(dps.get(item.getCode()));
continue;
}
if (item.getDpId() != null && dps.containsKey(item.getDpId())) { if (item.getDpId() != null && dps.containsKey(item.getDpId())) {
item.setValue(dps.get(item.getDpId())); item.setValue(dps.get(item.getDpId()));
} }
@ -172,10 +186,44 @@ public class ControlSchemaServiceImpl implements ControlSchemaService {
item.setEnumValues(parseEnum(p.getEnumList())); item.setEnumValues(parseEnum(p.getEnumList()));
String access = p.getAccess() == null ? "" : p.getAccess().toLowerCase(Locale.ROOT); String access = p.getAccess() == null ? "" : p.getAccess().toLowerCase(Locale.ROOT);
item.setWritable(access.contains("write")); item.setWritable(access.contains("write"));
// 语义字段:物模型已落库则直接用;未落库(旧数据)则即时推导,保证客户端始终拿得到语义
String semantic = p.getSemantic();
String grp = p.getGrp();
if (!StringUtils.hasText(semantic) || !StringUtils.hasText(grp)) {
SpecSemanticResolver.Semantic s = semanticResolver.resolve(
p.getCode(), p.getName(), p.getType(), p.getAccess());
if (!StringUtils.hasText(semantic)) {
semantic = s.getSemantic();
}
if (!StringUtils.hasText(grp)) {
grp = s.getGrp();
}
// 可写性修正:状态量被误标 write 时强制只读
if (semanticResolver.isReadOnly(semantic, grp, p.getCode())) {
item.setWritable(false);
}
}
item.setSemantic(semantic);
item.setGrp(grp);
item.setIsPrimary(p.getIsPrimary());
item.setEnumLabels(parseEnumLabels(p.getEnumLabel()));
item.setWidget(resolveWidget(item)); item.setWidget(resolveWidget(item));
return item; return item;
} }
/** 解析枚举中文名映射({"warm":"暖风"})。 */
private Map<String, String> parseEnumLabels(String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
return objectMapper.readValue(json, new TypeReference<Map<String, String>>() {
});
} catch (Exception e) {
return null;
}
}
private String resolveWidget(ControlItem item) { private String resolveWidget(ControlItem item) {
String type = item.getType() == null ? "" : item.getType().toLowerCase(Locale.ROOT); String type = item.getType() == null ? "" : item.getType().toLowerCase(Locale.ROOT);
String name = item.getName() == null ? "" : item.getName().toLowerCase(Locale.ROOT); String name = item.getName() == null ? "" : item.getName().toLowerCase(Locale.ROOT);
@ -195,8 +243,28 @@ public class ControlSchemaServiceImpl implements ControlSchemaService {
return "text"; return "text";
} }
/** 主开关:优先匹配 开关/电源/switch/power;否则唯一可写 bool */ /**
* 主开关识别。
*
* <p><b>重构</b>:原实现按名称含 switch/power/开关/电源 猜测,
* 会把「背光开关」误判成主开关(JD2BTSA 误控背光的根因)。
* 现改为优先使用物模型落库的 {@code is_primary} / {@code semantic},
* 由 {@link SpecSemanticResolver} 单一权威判定;物模型无语义字段时才回退旧的启发式。</p>
*/
private String pickMainSwitch(List<ControlItem> writable) { private String pickMainSwitch(List<ControlItem> writable) {
// 1) 物模型已显式标记主开关
for (ControlItem i : writable) {
if (i.getIsPrimary() != null && i.getIsPrimary() == 1) {
return i.getCode();
}
}
// 2) 语义为 power
for (ControlItem i : writable) {
if (SpecSemanticResolver.POWER.equals(i.getSemantic()) && i.isWritable()) {
return i.getCode();
}
}
// 3) 回退:旧启发式(仅在物模型无语义字段时才会走到)
List<ControlItem> bools = writable.stream() List<ControlItem> bools = writable.stream()
.filter(i -> "switch".equals(i.getWidget())) .filter(i -> "switch".equals(i.getWidget()))
.toList(); .toList();

View File

@ -3,6 +3,8 @@ package com.iot.device.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.iot.common.constants.PlatformType; import com.iot.common.constants.PlatformType;
import java.util.ArrayList;
import com.iot.device.dto.ProductSpec; import com.iot.device.dto.ProductSpec;
import com.iot.device.entity.Device; import com.iot.device.entity.Device;
import com.iot.device.entity.Product; import com.iot.device.entity.Product;
@ -16,6 +18,7 @@ import com.iot.device.mapper.ProductMapper;
import com.iot.device.mapper.ProductPropertyMapper; import com.iot.device.mapper.ProductPropertyMapper;
import com.iot.device.service.ProductService; import com.iot.device.service.ProductService;
import com.iot.device.service.TuyaCloudService; import com.iot.device.service.TuyaCloudService;
import com.iot.device.service.spec.SpecSemanticResolver;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -38,6 +41,7 @@ public class ProductServiceImpl implements ProductService {
private final ProductActionMapper productActionMapper; private final ProductActionMapper productActionMapper;
private final DeviceMapper deviceMapper; private final DeviceMapper deviceMapper;
private final TuyaCloudService tuyaCloudService; private final TuyaCloudService tuyaCloudService;
private final SpecSemanticResolver semanticResolver;
@Override @Override
@Transactional @Transactional
@ -327,8 +331,19 @@ public class ProductServiceImpl implements ProductService {
} }
} }
pp.setSort(sort++); pp.setSort(sort++);
// 语义落库(SpecSemanticResolver 为唯一权威):
// 1) semantic / grp 决定客户端如何渲染、能否当主开关;
// 2) 修正 access —— 涂鸦 fnCodes 为空时兜底成"全部可写",
// 会把 scene_1~6 / relay_status / countdown 等状态量误标为可写。
SpecSemanticResolver.Semantic sem = semanticResolver.resolve(
pp.getCode(), pp.getName(), pp.getType(), pp.getAccess());
pp.setSemantic(sem.getSemantic());
pp.setGrp(sem.getGrp());
pp.setAccess(sem.isWritable() ? "write" : "read");
productPropertyMapper.insert(pp); productPropertyMapper.insert(pp);
} }
// 选主开关并回填 is_primary(详情页大圆钮的唯一依据)
applyPrimary(p.getId());
p.setSpecVersion(p.getSpecVersion() == null ? 1 : p.getSpecVersion() + 1); p.setSpecVersion(p.getSpecVersion() == null ? 1 : p.getSpecVersion() + 1);
productMapper.updateById(p); productMapper.updateById(p);
total++; total++;
@ -336,6 +351,37 @@ public class ProductServiceImpl implements ProductService {
return total; return total;
} }
/**
* 依据语义规则选出产品的主开关功能点,并回填 is_primary。
*/
private void applyPrimary(Long productId) {
List<ProductProperty> props = productPropertyMapper.selectList(
new LambdaQueryWrapper<ProductProperty>().eq(ProductProperty::getProductId, productId));
if (props.isEmpty()) {
return;
}
List<SpecSemanticResolver.ProductPropertyView> views = new ArrayList<>();
for (ProductProperty pp : props) {
SpecSemanticResolver.Semantic sem = semanticResolver.resolve(
pp.getCode(), pp.getName(), pp.getType(), pp.getAccess());
views.add(new SpecSemanticResolver.ProductPropertyView(
pp.getCode(), pp.getName(), pp.getType(),
pp.getSemantic() != null ? pp.getSemantic() : sem.getSemantic(),
pp.getGrp() != null ? pp.getGrp() : sem.getGrp(),
sem.isWritable(), pp.getSort()));
}
String primary = semanticResolver.pickPrimary(views);
for (ProductProperty pp : props) {
int want = (primary != null && primary.equals(pp.getCode())) ? 1 : 0;
if (pp.getIsPrimary() == null || pp.getIsPrimary() != want) {
ProductProperty upd = new ProductProperty();
upd.setId(pp.getId());
upd.setIsPrimary(want);
productPropertyMapper.updateById(upd);
}
}
}
private String mapTuyaType(String t) { private String mapTuyaType(String t) {
if (t == null) { if (t == null) {
return "string"; return "string";

View File

@ -0,0 +1,413 @@
package com.iot.device.service.spec;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 物模型语义解析器 —— 功能点语义规则的唯一权威。
*
* <p>重构前,同一个"哪个功能点是主开关、哪些只是状态量"的问题有两份启发式实现:</p>
* <ul>
* <li>Android 端 TuyaSdkService.switchDpScore / isNonPowerSwitchName
* (26 个中英排除词 + 100/90/60/40/10 五档打分)</li>
* <li>后端 ControlSchemaServiceImpl.pickMainSwitch
* (名称含 switch/power/开关/电源)</li>
* </ul>
* <p>两份实现口径不一致,是 JD2BTSA「背光开关被当成主开关误控」以及
* JD6BTSA「scene_1~6 / countdown / relay_status 等状态量被渲染成可写控件」的直接根因。
* 现统一收敛到本类,客户端只消费物模型落库的 semantic / grp / is_primary 字段。</p>
*/
@Component
public class SpecSemanticResolver {
// ==================== 语义标签常量 ====================
public static final String POWER = "power";
public static final String SWITCH = "switch";
public static final String BACKLIGHT = "backlight";
public static final String INDICATOR = "indicator";
public static final String BRIGHTNESS = "brightness";
public static final String COLOR_TEMP = "color_temp";
public static final String MODE = "mode";
public static final String SCENE = "scene";
public static final String COUNTDOWN = "countdown";
public static final String CHILD_LOCK = "child_lock";
public static final String TEMPERATURE = "temperature";
public static final String HUMIDITY = "humidity";
public static final String BATTERY = "battery";
public static final String MOTION = "motion";
public static final String CONTACT = "contact";
public static final String FAULT = "fault";
public static final String RAW = "raw";
// ==================== 功能分组常量 ====================
public static final String GRP_SWITCH = "switch";
public static final String GRP_SCENE = "scene";
public static final String GRP_MODE = "mode";
public static final String GRP_COUNTDOWN = "countdown";
public static final String GRP_DISPLAY = "display";
public static final String GRP_CONFIG = "config";
public static final String GRP_MEASURE = "measure";
public static final String GRP_STATUS = "status";
/** 非主控语义词表:命中即判定为状态量/配置项,永远不能被当作主开关。 */
private static final Map<String, String> NON_CONTROL = buildNonControl();
/** 功能语义词表:命中即给出功能语义。 */
private static final Map<String, String> FUNCTION = buildFunction();
/**
* 只读语义集合:即使物模型里被标成 write,也应视为只读状态量。
* 根因:涂鸦物模型同步时用 fnCodes.contains(code) 判定可写,
* 而 fnCodes 为空时兜底成"全部可写",导致状态量被误标。
*/
private static final Set<String> READ_ONLY_SEMANTICS = Set.of(SCENE, FAULT);
/** 明确不可作为主开关的分组。 */
private static final Set<String> NON_PRIMARY_GROUPS = Set.of(
GRP_SCENE, GRP_MODE, GRP_COUNTDOWN, GRP_DISPLAY, GRP_CONFIG, GRP_MEASURE, GRP_STATUS);
/**
* 解析单个功能点的语义。
*
* @param code 功能点编码(如 switch_1 / backlight_switch)
* @param name 功能点名称(如 开关 / 背光开关)
* @param type 数据类型(bool/int/enum/...)
* @param access 声明的访问权限(read/write/notify)
*/
public Semantic resolve(String code, String name, String type, String access) {
String c = norm(code);
String n = norm(name);
String both = (c + " " + n).trim();
String semantic = null;
String grp = matchGroup(c);
// 1) 先判"非主控"(背光/指示灯/倒计时/状态量…)——最容易被误认成主开关
for (Map.Entry<String, String> e : NON_CONTROL.entrySet()) {
if (containsWord(both, e.getKey())) {
semantic = e.getValue();
break;
}
}
// 2) 再判功能语义
if (semantic == null) {
for (Map.Entry<String, String> e : FUNCTION.entrySet()) {
if (containsWord(both, e.getKey())) {
semantic = e.getValue();
break;
}
}
}
// 3) 开关类兜底
if (semantic == null && isSwitchWord(both)) {
semantic = SWITCH;
}
if (semantic == null) {
semantic = RAW;
}
// 分组兜底
if (grp == null) {
grp = defaultGroup(semantic);
}
// 可写性修正:声明 access 含 write,但语义属只读状态量 → 强制只读
boolean declaredWritable = access != null && access.toLowerCase(Locale.ROOT).contains("write");
boolean writable = declaredWritable && !isReadOnly(semantic, grp, code);
return new Semantic(semantic, grp, writable, false);
}
/**
* 在一个产品的物模型范围内选出主开关(is_primary)。
*
* <p>规则:</p>
* <ol>
* <li>优先取语义为 power 且可写的功能点(sort 最小者);</li>
* <li>其次取 switch 分组内 sort 最小的可写开关通道(多通道设备以首个为详情页主开关,
* 列表页仍按 grp 整组联动);</li>
* <li>都没有则无主开关(客户端退化为普通控件列表)。</li>
* </ol>
*
* @return 主开关的 code;无主开关返回 null
*/
public String pickPrimary(List<ProductPropertyView> props) {
if (props == null || props.isEmpty()) {
return null;
}
List<ProductPropertyView> sorted = new ArrayList<>(props);
sorted.sort((a, b) -> Integer.compare(nz(a.getSort()), nz(b.getSort())));
// 1) 明确标记为 power 的
for (ProductPropertyView p : sorted) {
if (POWER.equals(p.getSemantic()) && p.isWritable()) {
return p.getCode();
}
}
// 2) switch 分组内首个可写通道
for (ProductPropertyView p : sorted) {
if (p.isWritable()
&& !NON_PRIMARY_GROUPS.contains(p.getGrp())
&& (SWITCH.equals(p.getSemantic()) || GRP_SWITCH.equals(p.getGrp()))) {
return p.getCode();
}
}
// 3) 兜底:全产品唯一可写布尔
ProductPropertyView only = null;
int writableBoolCount = 0;
for (ProductPropertyView p : sorted) {
if (p.isWritable() && "bool".equalsIgnoreCase(p.getType())) {
writableBoolCount++;
if (only == null) {
only = p;
}
}
}
return (writableBoolCount == 1 && only != null) ? only.getCode() : null;
}
/**
* 判断某语义/分组是否应视为只读状态量。
*/
public boolean isReadOnly(String semantic, String grp, String code) {
if (semantic != null && READ_ONLY_SEMANTICS.contains(semantic)) {
return true;
}
if (grp != null && (GRP_STATUS.equals(grp) || GRP_SCENE.equals(grp))) {
return true;
}
// relay_status* 这类明确的状态上报前缀
return code != null && code.toLowerCase(Locale.ROOT).startsWith("relay_status");
}
/** 判断某分组是否为「开关通道组」(设备列表整组联动的依据)。 */
public boolean isSwitchGroup(String grp) {
return GRP_SWITCH.equals(grp);
}
// ==================== 内部规则 ====================
private static Map<String, String> buildNonControl() {
Map<String, String> m = new LinkedHashMap<>();
// 背光 / 指示灯 / 氛围
m.put("背光", BACKLIGHT);
m.put("backlight", BACKLIGHT);
m.put("指示灯", INDICATOR);
m.put("指示", INDICATOR);
m.put("indicator", INDICATOR);
m.put("氛围灯", INDICATOR);
m.put("氛围", INDICATOR);
m.put("ambience", INDICATOR);
m.put("夜灯", INDICATOR);
m.put("night", INDICATOR);
m.put("灯效", INDICATOR);
m.put("彩灯", INDICATOR);
m.put("led", INDICATOR);
// 倒计时 / 定时
m.put("倒计时", COUNTDOWN);
m.put("countdown", COUNTDOWN);
m.put("定时", COUNTDOWN);
m.put("timer", COUNTDOWN);
// 童锁
m.put("童锁", CHILD_LOCK);
m.put("childlock", CHILD_LOCK);
m.put("child lock", CHILD_LOCK);
// 故障 / 报警
m.put("故障", FAULT);
m.put("fault", FAULT);
m.put("报警", FAULT);
m.put("alarm", FAULT);
// 其余状态/配置量:归为原始语义,绝不作为主开关
for (String w : List.of("状态", "status", "state", "剩余", "remaining", "累计",
"保温", "节能", "省电", "eco", "恢复", "出厂", "设置", "setting", "reset",
"锁定", "lock", "配网", "校准")) {
m.put(w, RAW);
}
return m;
}
private static Map<String, String> buildFunction() {
Map<String, String> m = new LinkedHashMap<>();
m.put("亮度", BRIGHTNESS);
m.put("brightness", BRIGHTNESS);
m.put("调光", BRIGHTNESS);
m.put("色温", COLOR_TEMP);
m.put("colortemp", COLOR_TEMP);
m.put("colortempvalue", COLOR_TEMP);
m.put("模式", MODE);
m.put("mode", MODE);
m.put("场景", SCENE);
m.put("scene", SCENE);
m.put("preset", SCENE);
m.put("温度", TEMPERATURE);
m.put("temperature", TEMPERATURE);
m.put("湿度", HUMIDITY);
m.put("humidity", HUMIDITY);
m.put("电量", BATTERY);
m.put("battery", BATTERY);
m.put("人体", MOTION);
m.put("motion", MOTION);
m.put("pir", MOTION);
m.put("门磁", CONTACT);
m.put("contact", CONTACT);
return m;
}
/** 分组匹配:优先按编码前缀(switch_1 → switch)。 */
private String matchGroup(String normCode) {
String c = normCode;
if (c.startsWith("switch")) {
return GRP_SWITCH;
}
if (c.startsWith("scene")) {
return GRP_SCENE;
}
if (c.startsWith("mode")) {
return GRP_MODE;
}
if (c.startsWith("countdown") || c.startsWith("timer")) {
return GRP_COUNTDOWN;
}
if (c.startsWith("relaystatus")) {
return GRP_STATUS;
}
if (c.startsWith("backlight")) {
return GRP_DISPLAY;
}
if (c.startsWith("battery")) {
return GRP_MEASURE;
}
return null;
}
private String defaultGroup(String semantic) {
if (semantic == null) {
return null;
}
return switch (semantic) {
case SWITCH, POWER -> GRP_SWITCH;
case SCENE -> GRP_SCENE;
case MODE -> GRP_MODE;
case COUNTDOWN -> GRP_COUNTDOWN;
case BACKLIGHT, INDICATOR -> GRP_DISPLAY;
case TEMPERATURE, HUMIDITY, BATTERY -> GRP_MEASURE;
case FAULT -> GRP_STATUS;
default -> null;
};
}
/** 归一化:小写 + 去掉空格/下划线/连字符/点。 */
private String norm(String s) {
if (s == null) {
return "";
}
return s.trim().toLowerCase(Locale.ROOT)
.replace(" ", "").replace("_", "").replace("-", "").replace(".", "");
}
/** 词包含判断:归一化后的串里包含该词。 */
private boolean containsWord(String normalizedBoth, String word) {
if (word == null || word.isEmpty()) {
return false;
}
String w = word.toLowerCase(Locale.ROOT).replace(" ", "");
return normalizedBoth.contains(w);
}
private boolean isSwitchWord(String both) {
return both.contains("开关") || both.contains("switch") || both.contains("电源")
|| both.contains("power") || both.contains("onoff") || both.contains("通电")
|| both.contains("断电") || both.contains("供电") || both.contains("relay");
}
private static int nz(Integer v) {
return v == null ? Integer.MAX_VALUE : v;
}
// ==================== 值对象 ====================
/** 语义解析结果。 */
public static class Semantic {
private final String semantic;
private final String grp;
private final boolean writable;
private final boolean primary;
public Semantic(String semantic, String grp, boolean writable, boolean primary) {
this.semantic = semantic;
this.grp = grp;
this.writable = writable;
this.primary = primary;
}
public String getSemantic() {
return semantic;
}
public String getGrp() {
return grp;
}
public boolean isWritable() {
return writable;
}
public boolean isPrimary() {
return primary;
}
}
/** 物模型功能点视图(供主开关挑选,避免直接依赖实体层)。 */
public static class ProductPropertyView {
private final String code;
private final String name;
private final String type;
private final String semantic;
private final String grp;
private final boolean writable;
private final Integer sort;
public ProductPropertyView(String code, String name, String type, String semantic,
String grp, boolean writable, Integer sort) {
this.code = code;
this.name = name;
this.type = type;
this.semantic = semantic;
this.grp = grp;
this.writable = writable;
this.sort = sort;
}
public String getCode() {
return code;
}
public String getName() {
return name;
}
public String getType() {
return type;
}
public String getSemantic() {
return semantic;
}
public String getGrp() {
return grp;
}
public boolean isWritable() {
return writable;
}
public Integer getSort() {
return sort;
}
}
}