From 8864ec61e33d79d77718c8f8b57aeeb432e3345e Mon Sep 17 00:00:00 2001 From: Jimmy Tanagra Date: Sat, 1 Apr 2023 00:39:46 +1000 Subject: [PATCH] allow creating custom semantic tags Signed-off-by: Jimmy Tanagra --- CHANGELOG.md | 1 + features/semantics.feature | 60 +++++++ lib/openhab/core/items/semantics.rb | 18 +- .../core/items/semantics/custom_semantic.rb | 167 ++++++++++++++++++ spec/openhab/core/items/semantics_spec.rb | 8 + 5 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 features/semantics.feature create mode 100644 lib/openhab/core/items/semantics/custom_semantic.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index ef7ca29f9c..89141d4411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - {OpenHAB::Core::Rules::Registry#scenes rules.scenes} and {OpenHAB::Core::Rules::Registry#scripts rules.scripts} are available as convenient shortcuts to filter for rules that are scenes or scripts. +- Support adding custom semantic tags with {OpenHAB::Core::Items::Semantics.add Semantics.add} ### Bug Fixes diff --git a/features/semantics.feature b/features/semantics.feature new file mode 100644 index 0000000000..7b3476960c --- /dev/null +++ b/features/semantics.feature @@ -0,0 +1,60 @@ +Feature: semantics + Custom semantic tag creation + + Background: + Given Clean OpenHAB with latest Ruby Libraries + + Scenario: Semantic tag added + Given a rule + """ + locale = java.util.Locale.default + + Semantics.add(: Semantics::) + member_of_tag = Semantics::.java_class < org.openhab.core.semantics.Tag.java_class + found_in_aggregator = org.openhab.core.semantics.model...stream.any_match { |l| l == Semantics::.java_class } + found_by_id = org.openhab.core.semantics.SemanticTags.get_by_id("") == Semantics::.java_class + found_by_label = org.openhab.core.semantics.SemanticTags.get_by_label("", locale) == Semantics::.java_class + logger.info "#{Semantics::.java_class}: #{member_of_tag} #{found_in_aggregator} #{found_by_id} #{found_by_label}" + """ + When I deploy the rule + Then It should log 'org.openhab.core.semantics.model..: true true true true' within 5 seconds + Examples: + | tag | parent | type | aggregator | default_label | + | SecretRoom2 | Room | location | Locations | Secret Room 2 | + | Equipment9 | Lightbulb | equipment | Equipments | Equipment 9 | + | Pointier | Control | point | Points | Pointier | + | Property8 | Property | property | Properties | Property 8 | + + Scenario: Supports custom label + Given a rule + """ + Semantics.add(Room1: Semantics::Room, label: "My Custom Label") + logger.info org.openhab.core.semantics.SemanticTags.get_label(Semantics::Room1, java.util.Locale.default) + """ + When I deploy the rule + Then It should log 'My Custom Label' within 5 seconds + + # There's a bug in openhab, it doesn't load the synonyms other than from the resource bundle + # @wip + # Scenario: Support synonyms + # Given a rule + # """ + # Semantics.add(Room2: Semantics::Room, synonyms: "Alias1") + # logger.info org.openhab.core.semantics.SemanticTags.get_by_label_or_synonym("Alias1", java.util.Locale.default) + # """ + # When I deploy the rule + # Then It should log 'org.openhab.core.semantics.model.location.Room2' within 5 seconds + + Scenario: Semantic tag usable by items + Given a rule + """ + Semantics.add(: Semantics::) + items.build { _item "", tags: Semantics:: } + logger.info " is a ? #{.?}" + """ + When I deploy the rule + Then It should log ' is a ? ' within 5 seconds + Examples: + | tag | parent | semantic_type | item_type | item_name | result | + | Color | Light | point | color | LightColor | true | + | TorpedoLauncher | Equipment | equipment | contact | Launcher1 | true | diff --git a/lib/openhab/core/items/semantics.rb b/lib/openhab/core/items/semantics.rb index 45d57808da..154721b59b 100644 --- a/lib/openhab/core/items/semantics.rb +++ b/lib/openhab/core/items/semantics.rb @@ -156,6 +156,13 @@ module Items # # All items tagged "SmartLightControl" # items.tagged("SmartLightControl") # + # ## Creating Custom Semantic Tags + # + # This library provides an experimental support for creating custom semantic tags. + # It can be used to augment the standard set of tags from openHAB with additional + # tags that will better suit your particular requirements. + # + # For more information, see {add} # module Semantics GenericItem.include(self) @@ -188,11 +195,14 @@ module Semantics # end # + # @!visibility private + TAGS_REGISTRIES = [org.openhab.core.semantics.model.point.Points, + org.openhab.core.semantics.model.property.Properties, + org.openhab.core.semantics.model.equipment.Equipments, + org.openhab.core.semantics.model.location.Locations].freeze + # import all the semantics constants - [org.openhab.core.semantics.model.point.Points, - org.openhab.core.semantics.model.property.Properties, - org.openhab.core.semantics.model.equipment.Equipments, - org.openhab.core.semantics.model.location.Locations].each do |parent_tag| + TAGS_REGISTRIES.each do |parent_tag| parent_tag.stream.for_each do |tag| const_set(tag.simple_name.to_sym, tag.ruby_class) end diff --git a/lib/openhab/core/items/semantics/custom_semantic.rb b/lib/openhab/core/items/semantics/custom_semantic.rb new file mode 100644 index 0000000000..52540a142b --- /dev/null +++ b/lib/openhab/core/items/semantics/custom_semantic.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +java_import org.objectweb.asm.ClassWriter +java_import org.objectweb.asm.Opcodes + +module OpenHAB + module Core + module Items + module Semantics + # + # Utility to add custom Semantic tags + # + # @!visibility private + class CustomSemantic + class << self + # + # Adds a custom semantic tag. + # + # @param [Symbol,String] name The name of the new semantic tag to add + # @param [String, Semantics::Tag] parent The semantic class of the parent tag + # @param [String] label An optional label. If not provided, it will be generated from name by + # splitting up the CamelCase with a space + # @param [String] synonyms A comma separated list of synonyms. + # @param [String] description A longer description. + # + # @return [Semantics::Tag,nil] The added semantic tag class, + # or nil if the tag had already been registered. + # + def add(name, parent, label: nil, synonyms: "", description: "") + name = name.to_s + + return if name == self.class.name + + unless /^[A-Z][a-zA-Z0-9]+$/.match?(name) + raise "Name must start with a capital letter and not contain any spaces" + end + + class_loader = org.openhab.core.semantics.SemanticTags.java_class.class_loader + + parent = Semantics.const_get(parent) if parent.is_a?(String) + valid_types = [Semantics::Location, Semantics::Equipment, Semantics::Point, Semantics::Property] + type = valid_types.find { |t| parent == t || parent < t } + raise "Parent must be one of #{valid_types} or their descendants" unless type + + type = type.java_class.simple_name.downcase + class_name = "org.openhab.core.semantics.model.#{type}.#{name}" + + return unless class_loader.find_class(nil, class_name).nil? + + internal_parent_name = parent.java_class.name.tr(".", "/") + internal_class_name = class_name.tr(".", "/") + + # CamelCaseALL99 -> Camel Case ALL 99 + label ||= name.gsub(/(([A-Z][a-z]+)|([A-Z][A-Z]+)|([0-9]+))/, " \\1").strip + + # Create the class/interface + class_writer = ClassWriter.new(0) + class_writer.visit(Opcodes::V11, Opcodes::ACC_PUBLIC + Opcodes::ACC_ABSTRACT + Opcodes::ACC_INTERFACE, + internal_class_name, nil, "java/lang/Object", [internal_parent_name]) + + # Add TagInfo Annotation + class_writer.visit_source("Status.java", nil) + parent.java_class.get_annotation(org.openhab.core.semantics.TagInfo.java_class).id.then do |parent_id| + # Correct a bug in openhab, Semantics::Property's id is `MeasurementProperty` instead of Property + parent_id = "Property" if parent_id == "MeasurementProperty" # @deprecated OH3.4 + + annotation_visitor = class_writer.visit_annotation("Lorg/openhab/core/semantics/TagInfo;", true) + annotation_visitor.visit("id", "#{parent_id}_#{name}") + annotation_visitor.visit("label", label) + annotation_visitor.visit("synonyms", synonyms) + annotation_visitor.visit("description", description) + annotation_visitor.visit_end + end + + class_writer.visit_end + byte_code = class_writer.to_byte_array + + java_klass = class_loader.define_class(class_name, byte_code, 0, byte_code.length) + + register_tag(java_klass) + java_klass.ruby_class + end + + private + + def register_tag(java_klass) + id = java_klass.get_annotation(org.openhab.core.semantics.TagInfo.java_class).id + type = id.split("_").first + + # Add to org.openhab.core.semantics.model.location.Locations.LOCATIONS + type_plural = "#{type.sub(/y$/, "ie")}s" # pluralize, Property -> Properties, Location -> Locations + field_name = type_plural.upcase.to_sym + type_aggregator = java_import("org.openhab.core.semantics.model.#{type.downcase}.#{type_plural}").first + type_aggregator.field_reader field_name + members_list = type_aggregator.send(field_name) + members_list.add(java_klass) + + # Add to org.openhab.core.semantics.SemanticTags.TAGS + # by calling the private method SemanticTags.addTagSet + semantic_tags = org.openhab.core.semantics.SemanticTags.java_class + add_tag_set = semantic_tags.declared_method(:addTagSet, java.lang.Class.java_class) + add_tag_set.accessible = true + add_tag_set.invoke(semantic_tags, java_klass) + add_tag_set.accessible = false + end + end + end + + # + # Adds custom semantic tags. + # + # @return [Semantics::Tag] The added semantic tag class + # + # @overload self.add(**tags) + # Quickly add one or more semantic tags using the default label, empty synonyms and descriptions. + # + # @param [kwargs] **tags One or more `tag` => `parent` pairs + # @return [Boolean] true if all tags were added successfully + # + # @example Add one semantic tag `Balcony` whose parent is `Semantics::Outdoor` (Location) + # Semantics.add(Balcony: Semantics::Outdoor) + # + # @example Add multiple semantic tags + # Semantics.add(Balcony: Semantics::Outdoor, + # SecretRoom: Semantics::Room, + # Motion: Semantics::Property) + # + # @overload self.add(label: nil, synonyms: "", description: "", **tags) + # Add a custom semantic tag with extra details. + # + # @example + # Semantics.add(SecretRoom: Semantics::Room, label: "My Secret Room", + # synonyms: "HidingPlace", description: "A room that requires a special trick to enter") + # + # @param [String,nil] label Optional label. When nil, infer the label from the tag name, + # converting `CamelCase` to `Camel Case` + # @param [String] synonyms A comma separated list of synonyms for this tag. + # @param [String] description A longer description of the tag. + # @param [kwargs] **tags Exactly one pair of `tag` => `parent` + # @return [Boolean] true if the tag was added successfully + # + def self.add(label: nil, synonyms: "", description: "", **tags) + raise "Tags must be specified" if tags.empty? + if (tags.length > 1) && !(label.nil? && synonyms.empty? && description.empty?) + raise "Additional options can only be specified when creating one tag" + end + + tags.map do |name, parent| + CustomSemantic.add(name, parent, label: label, synonyms: synonyms, description: description) + end.any?(&:!) + end + + # + # Automatically looks up new semantic classes and adds them as `constants` + # + # @return [Tag, nil] + # + def self.const_missing(sym) + logger.warn("const missing, performing Semantics Lookup for: #{sym}") + TAGS_REGISTRIES.lazy.flat_map { |tags| tags.stream.to_list } + .find { |tag| tag.simple_name.to_sym == sym } + &.then { |tag| const_set(tag.simple_name.to_sym, tag.ruby_class) } + end + end + end + end +end diff --git a/spec/openhab/core/items/semantics_spec.rb b/spec/openhab/core/items/semantics_spec.rb index 63c2c322a9..f901d95091 100644 --- a/spec/openhab/core/items/semantics_spec.rb +++ b/spec/openhab/core/items/semantics_spec.rb @@ -271,4 +271,12 @@ def points(*args) expect(gIndoor.equipments).to match_array([Group_Equipment, NonGroup_Equipment]) end end + + describe "#add" do + it "supports creating multiple tags" do + allow(Semantics::CustomSemantic).to receive(:add).and_return(true) + expect(Semantics::CustomSemantic).to receive(:add).twice + Semantics.add(Room1: Semantics::Room, Property2: Semantics::Property) + end + end end