Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Log warnings when an Item's semantic structure is incorrect #4613

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,44 @@ public class SemanticTags {
}
}

/**
* Determines the semantic root of a given tag type.
*
* @param type the tag type
* @return the semantic root of the tag type, or null if the type is not a semantic tag
*/
public static @Nullable Class<? extends Tag> getSemanticRoot(Class<? extends Tag> type) {
if (type == null) {
return null;
}
if (Point.class.isAssignableFrom(type)) {
return Point.class;
} else if (Property.class.isAssignableFrom(type)) {
return Property.class;
} else if (Location.class.isAssignableFrom(type)) {
return Location.class;
} else if (Equipment.class.isAssignableFrom(type)) {
return Equipment.class;
} else {
return null;
}
}

/**
* Determines the name of the semantic root of a given tag type.
*
* @param type the tag type
* @return the name of the semantic root of the tag type, or an empty string if the type is not a semantic tag
*/
public static String getSemanticRootName(Class<? extends Tag> type) {
Class<? extends Tag> semanticRoot = getSemanticRoot(type);
if (semanticRoot != null) {
return semanticRoot.getSimpleName();
} else {
return "";
}
}

/**
* Determines the {@link Property} type that a {@link Point} relates to.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
Expand All @@ -25,8 +26,10 @@

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.items.GroupItem;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemPredicates;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.Metadata;
Expand All @@ -35,27 +38,33 @@
import org.openhab.core.semantics.Equipment;
import org.openhab.core.semantics.Location;
import org.openhab.core.semantics.Point;
import org.openhab.core.semantics.Property;
import org.openhab.core.semantics.SemanticTag;
import org.openhab.core.semantics.SemanticTagRegistry;
import org.openhab.core.semantics.SemanticTags;
import org.openhab.core.semantics.SemanticsPredicates;
import org.openhab.core.semantics.SemanticsService;
import org.openhab.core.semantics.Tag;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;

/**
* The internal implementation of the {@link SemanticsService} interface, which is registered as an OSGi service.
*
* @author Kai Kreuzer - Initial contribution
* @author Laurent Garnier - Few methods moved from class SemanticTags in order to use the semantic tag registry
* @author Jimmy Tanagra - Add Item semantic tag validation
*/
@NonNullByDefault
@Component
public class SemanticsServiceImpl implements SemanticsService {
@Component(immediate = true)
public class SemanticsServiceImpl implements SemanticsService, RegistryChangeListener<Item> {

private static final String SYNONYMS_NAMESPACE = "synonyms";

private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(SemanticsServiceImpl.class);

private final ItemRegistry itemRegistry;
private final MetadataRegistry metadataRegistry;
private final SemanticTagRegistry semanticTagRegistry;
Expand All @@ -67,6 +76,14 @@ public SemanticsServiceImpl(final @Reference ItemRegistry itemRegistry,
this.itemRegistry = itemRegistry;
this.metadataRegistry = metadataRegistry;
this.semanticTagRegistry = semanticTagRegistry;

this.itemRegistry.stream().forEach(this::checkSemantics);
this.itemRegistry.addRegistryChangeListener(this);
}

@Deactivate
public void deactivate() {
itemRegistry.removeRegistryChangeListener(this);
}

@Override
Expand Down Expand Up @@ -158,4 +175,197 @@ private List<String> getLabelAndSynonyms(SemanticTag tag, Locale locale) {
Stream<String> synonyms = localizedTag.getSynonyms().stream();
return Stream.concat(label, synonyms).map(s -> s.toLowerCase(locale)).distinct().toList();
}

/**
* Validates the semantic tags of an item.
*
* It returns true only if one of the following is true:
* - No semantic tags at all
* - Only one Semantic tag of any kind.
* - Note: having only one Property tag is allowed. It implies that the item is a Point.
* - One Point tag and one Property tag
*
* It returns false if two Semantic tags are found, but they don't consist of one Point and one Property.
* It would also return false if more than two Semantic tags are found.
*
* @param item
* @param semanticTag the determined semantic tag of the item
* @return true if the item contains no semantic tags, or a valid combination of semantic tags, otherwise false
*/
boolean validateTags(Item item, @Nullable Class<? extends Tag> semanticTag) {
if (semanticTag == null) {
return true;
}
String semanticType = SemanticTags.getSemanticRootName(semanticTag);
// We're using Collectors here instead of Stream.toList() to resolve Java's wildcard capture conversion issue
List<Class<? extends Tag>> tags = item.getTags().stream().map(SemanticTags::getById).filter(Objects::nonNull)
.collect(Collectors.toList());
switch (tags.size()) {
case 0:
case 1:
return true;
case 2:
Class<? extends Tag> firstTag = tags.getFirst();
Class<? extends Tag> lastTag = tags.getLast();
if ((Point.class.isAssignableFrom(firstTag) && Property.class.isAssignableFrom(lastTag))
|| (Point.class.isAssignableFrom(lastTag) && Property.class.isAssignableFrom(firstTag))) {
return true;
}
String firstType = SemanticTags.getSemanticRootName(firstTag);
String lastType = SemanticTags.getSemanticRootName(lastTag);
if (firstType.equals(lastType)) {
if (Point.class.isAssignableFrom(firstTag) || Property.class.isAssignableFrom(firstTag)) {
logger.warn(
"Item '{}' ({}) has an invalid combination of semantic tags: {} ({}) and {} ({}). Only one Point and optionally one Property tag may be assigned.",
item.getName(), semanticType, firstTag.getSimpleName(), firstType,
lastTag.getSimpleName(), lastType);
} else {
logger.warn(
"Item '{}' ({}) has an invalid combination of semantic tags: {} ({}) and {} ({}). Only one {} tag may be assigned.",
item.getName(), semanticType, firstTag.getSimpleName(), firstType,
lastTag.getSimpleName(), lastType, firstType);
}
} else {
logger.warn(
"Item '{}' ({}) has an invalid combination of semantic tags: {} ({}) and {} ({}). {} and {} tags cannot be assigned at the same time.",
item.getName(), semanticType, firstTag.getSimpleName(), firstType, lastTag.getSimpleName(),
lastType, firstType, lastType);
}
return false;
default:
List<String> allTags = tags.stream().map(tag -> {
String tagType = SemanticTags.getSemanticRootName(tag);
return String.format("%s (%s)", tag.getSimpleName(), tagType);
}).toList();
logger.warn(
"Item '{}' ({}) has an invalid combination of semantic tags: {}. An item may only have one tag of Location, Equipment, or Point type. A Property tag may be assigned in conjunction with a Point tag.",
item.getName(), semanticType, allTags);
return false;
}
}

/**
* Verifies the semantics of an item and logs warnings if the semantics are invalid
*
* @param item
* @return true if the semantics are valid, false otherwise
*/
boolean checkSemantics(Item item) {
String itemName = item.getName();
Class<? extends Tag> semanticTag = SemanticTags.getSemanticType(item);
if (semanticTag == null) {
return true;
}

if (!validateTags(item, semanticTag)) {
return false;
}

List<String> warnings = new ArrayList<>();
List<String> parentLocations = new ArrayList<>();
List<String> parentEquipments = new ArrayList<>();

for (String groupName : item.getGroupNames()) {
try {
if (itemRegistry.getItem(groupName) instanceof GroupItem groupItem) {
Class<? extends Tag> groupSemanticType = SemanticTags.getSemanticType(groupItem);
if (groupSemanticType != null) {
if (Equipment.class.isAssignableFrom(groupSemanticType)) {
parentEquipments.add(groupName);
} else if (Location.class.isAssignableFrom(groupSemanticType)) {
parentLocations.add(groupName);
}
}
}
} catch (ItemNotFoundException e) {
// we don't care about invalid parent groups here
}
}

if (Point.class.isAssignableFrom(semanticTag)) {
if (parentLocations.size() == 1 && parentEquipments.size() == 1) {
// This case is allowed: a Point can belong to an Equipment and a Location
//
// Case 1:
// When a location contains multiple equipments -> temperature points,
// the average of the points will be used in the location's UI.
// However, when there is a point which is the direct member of the location,
// it will be used in the location's UI instead of the average.
// So setting one of the equipment's point as a direct member of the location
// allows to override the average.
//
// Case 2:
// When a central Equipment e.g. a HVAC contains Points located in multiple locations,
// e.g. room controls and sensors
String semanticType = SemanticTags.getSemanticRootName(semanticTag);
logger.info("Item '{}' ({}) belongs to location {} and equipment {}.", itemName, semanticType,
parentLocations, parentEquipments);
} else {
if (parentLocations.size() > 1) {
warnings.add(String.format(
"It belongs to multiple locations %s. It should only belong to one Equipment or one location, preferably not both at the same time.",
parentLocations.toString()));
}
if (parentEquipments.size() > 1) {
warnings.add(String.format(
"It belongs to multiple equipments %s. A Point can only belong to at most one Equipment.",
parentEquipments.toString()));
}
}
} else if (Equipment.class.isAssignableFrom(semanticTag)) {
if (parentLocations.size() > 0 && parentEquipments.size() > 0) {
warnings.add(String.format(
"It belongs to location(s) %s and equipment(s) %s. An Equipment can only belong to one Location or another Equipment, but not both.",
parentLocations.toString(), parentEquipments.toString()));
}
if (parentLocations.size() > 1) {
warnings.add(String.format(
"It belongs to multiple locations %s. An Equipment can only belong to one Location or another Equipment.",
parentLocations.toString()));
}
if (parentEquipments.size() > 1) {
warnings.add(String.format(
"It belongs to multiple equipments %s. An Equipment can only belong to at most one Equipment.",
parentEquipments.toString()));
}
} else if (Location.class.isAssignableFrom(semanticTag)) {
if (!(item instanceof GroupItem)) {
warnings.add(String.format("It is a %s item, not a group. A location should be a Group Item.",
item.getType()));
}
if (parentEquipments.size() > 0) {
warnings.add(String.format(
"It belongs to equipment(s) %s. A Location can only belong to another Location, not Equipment.",
parentEquipments.toString()));
}
if (parentLocations.size() > 1) {
warnings.add(
String.format("It belongs to multiple locations %s. It should only belong to one location.",
parentLocations.toString()));
}
}

if (!warnings.isEmpty()) {
String semanticType = SemanticTags.getSemanticRootName(semanticTag);
logger.warn("Item '{}' ({}) has invalid semantic structure: {}", itemName, semanticType,
String.join("\n", warnings));
return false;
}
return true;
}

@Override
public void added(Item item) {
checkSemantics(item);
}

@Override
public void removed(Item item) {
// nothing to do
}

@Override
public void updated(Item oldElement, Item element) {
checkSemantics(element);
}
}
Loading