Rules and rule templates YAML integration

I understood the question, what I tried to say is that I can’t assess that, but that I don’t think it will be difficult to adjust the code I’m working on to such a change if a change is required.

I’m not quite sure how to interpret that, do you mean to say that it doesn’t matter if they “enum strings” (keywords) are quoted or not?

The most important is that it consistently works to “parse the generated DSL back” to a rule, and that the Rule is identical. The second most important is that the users are content with the generated syntax, if they want to use them as .rules files.

My thought is that it’s about to identify when a type that doesn’t generate quotes can be used. All the rest will be considered “strings” and thus end up quoted.

I’m not sure about this, but using this logic:

    public ValidState createValidState(String stateValue) {
        Pattern NUMERIC_PATTERN = Pattern.compile("-?\\d+(\\.\\d+)?");
        Set<Class<? extends State>> enumStateClasses = Set.of(OnOffType.class, OpenClosedType.class, PlayPauseType.class, RewindFastforwardType.class, UpDownType.class, UnDefType.class);
        Set<String> enumStates = new HashSet<>();
        State[] states;
        for (Class<? extends State> enumClass : enumStateClasses) {
            if (!enumClass.isEnum()) {
                throw new AssertionError("Only enum states allowed");
            }
            states = enumClass.getEnumConstants();
            if (states != null) {
                for (State state : states) {
                    enumStates.add(state.toString());
                }
            }
        }

        ValidState result;
        if (NUMERIC_PATTERN.matcher(stateValue).matches()) {
            result = RulesFactory.eINSTANCE.createValidStateNumber();
        } else if (enumStates.contains(stateValue)) {
            result = RulesFactory.eINSTANCE.createValidStateId();
        } else {
            result = RulesFactory.eINSTANCE.createValidStateString();
        }
        result.setValue(stateValue);
        return result;
    }

…I get these results when serializing various DSL (test) rules:

rule "Self contained rule 1"
when
	Item MySwitch received command
then
	logInfo("test", "Global variable is not")
end

rule "Self contained rule 2"
when
	Item MySwitch received command
then
	logInfo("test", "Global variable is not in use")
end

rule "System started"
when
	System reached start level 100
then
	logDebug('l', 'System reached start level 100')
end

rule "mode TV"
when
	Item TvPower changed from OFF to ON
then
	logInfo("Rule","mode TV") 
	// STOP Sonos salon
	SonosSalonStop.sendCommand(ON)
end

rule "Initialize demo items"
when
	System started
then
	DemoLocation.postUpdate("52,9")
	DemoContact.postUpdate(OPEN)
	DemoString.postUpdate("Hello SmartHome!")
	DemoDateTime.postUpdate(new DateTimeType())
	DemoNumber.postUpdate(12.34)
end

(I will obviously make the enumeration of enum values and the Pattern static/initialized once, but during testing it’s more practical to have it all together)

Yes. ENUM Strings, like UNDEF or OFF, can be quoted or not quoted - it makes no difference - the effect is the same.

In fact any string containing only letters can - as state between WHEN and THEN - be quoted or not quoted - both work. I tried it, you can try this, too.

Even if the string contains space or “special characters”? I would think that the quotes were needed for the parser to categorize the elements of the statement.

Well, as far as I remember as state on the WHEN … THEN line 7 and "7" are the same. As stated above quantity literals like "7|W” must be quoted, not because the trigger will be build incorrectly, but because there is a parser error.

In fact any string containing only letters can …

Even if the string contains space or “special characters”?

My statement was about string containing only letters. Mixing letters and digits might also work without quotes - I have not tried it.

I tried it without quotes, it worked. The grammar in Rules.xtext is:

Rule: 
        'rule' name=(STRING|ID)
        'when' eventtrigger+=EventTrigger ('or' eventtrigger+=EventTrigger)*

EventTrigger: UpdateEventTrigger | CommandEventTrigger;

CommandEventTrigger:
        'Item' item=ItemName 'received command' (command=ValidCommand)?
;
UpdateEventTrigger:
        'Item' item=ItemName 'received update' (state=ValidState)?
;

ValidState:
    ValidStateId | ValidStateNumber | ValidStateString
;

ValidStateId:
    value=ID
;

ValidCommand:
    ValidCommandId | ValidCommandNumber | ValidCommandString
;

ValidCommandId:
    value=ID
;

// From Xtext.xtext
terminal ID: '^'?('a'..'z'|'A'..'Z'|'_') ('a'..'z'|'A'..'Z'|'_'|'0'..'9')*;
terminal STRING:
                        '"' ( '\\' . /* 'b'|'t'|'n'|'f'|'r'|'u'|'"'|"'"|'\\' */ | !('\\'|'"') )* '"' |
                        "'" ( '\\' . /* 'b'|'t'|'n'|'f'|'r'|'u'|'"'|"'"|'\\' */ | !('\\'|"'") )* "'"
                ;

So betweenrule … when either a single or double quoted string must be inserted, or an unquoted ID. And as valid state or command are accepted by the parser single-quoted strings, double-quoted strings and unquoted IDs.

Some triggers require valid State, others require valid Command and the set of valid enum states is different from the set of valid enum commands (I do not rermember now the details).

org.openhab.core.model.script.scoping.public Iterable<Type> getAllTypes() returns an Iterable containing all Commands and States, which are enums values.

I meant org.openhab.core.model.script.scoping.StateAndCommandProvider::getAllTypes().

Thanks, I managed to acquire an instance in the DslRuleFileConverter constructor, so I build two sets there, that I can use for matching later. I must handle States and Commands separately:

        StateAndCommandProvider provider = ScriptStandaloneSetup.getInjector().getInstance(StateAndCommandProvider.class);

        Set<String> enums = new LinkedHashSet<>();
        for (State state : provider.getAllStates()) {
            enums.add(state.toString());
        }
        this.enumStates = Set.copyOf(enums);

        enums = new LinkedHashSet<>();
        for (Command command : provider.getAllCommands()) {
            enums.add(command.toString());
        }
        this.enumCommands = Set.copyOf(enums);

I’ve implemented the logic discussed above for both States and Commands, so that they are unquoted if they are pure numbers (with an optional sign and decimal separator) or a “known enum keyword”. Everything else will be quoted in triggers. In the action script, no reformatting is done, it’s uses as-is.

I’ve now also handled formatting when there are multiple triggers. This file:

rule "test"
when
    Item test1 changed or
    Item test2 changed or
    Item test3 changed or
    Item test4 changed or
    Item test5 changed or
    Item test6 changed
then
    switch(triggeringItemName) {
        case "test1" : if(example1.state == ON) stuff.sendCommand(ON)
        case "test2" : if(example2.state == ON) stuff.sendCommand(ON)
        case "test3" : if(example3.state == ON) stuff.sendCommand(ON)
        case "test4" : if(example4.state == ON) stuff.sendCommand(ON)
        case "test5" : if(example5.state == ON) stuff.sendCommand(ON)
        case "test6" : if(example6.state == ON) stuff.sendCommand(ON)
    }
end

..serializes to:

rule "test"
when
	Item test1 changed or
	Item test2 changed or
	Item test3 changed or
	Item test4 changed or
	Item test5 changed or
	Item test6 changed
then
	switch(triggeringItemName) {
	    case "test1" : if(example1.state == ON) stuff.sendCommand(ON)
	    case "test2" : if(example2.state == ON) stuff.sendCommand(ON)
	    case "test3" : if(example3.state == ON) stuff.sendCommand(ON)
	    case "test4" : if(example4.state == ON) stuff.sendCommand(ON)
	    case "test5" : if(example5.state == ON) stuff.sendCommand(ON)
	    case "test6" : if(example6.state == ON) stuff.sendCommand(ON)
	}
end

I’ve also done a test on a managed rule (that doesn’t come from a .rules file), which looks like this in “old YAML”:

configuration:
  sourceItem: DemoColor
triggers:
  - id: "1"
    configuration:
      itemName: DemoColor
      previousState: "false"
      state: "true"
    type: core.ItemStateChangeTrigger
conditions: []
actions:
  - inputs: {}
    id: script
    configuration:
      type: application/vnd.openhab.dsl.rule
      script: |
        logInfo("Rule","mode TV")
        // STOP Sonos salon
        SonosSalonStop.sendCommand(ON)
    type: script.ScriptAction

The generated DSL looks like this:

rule "Mode TV template based rule"
when
	Item DemoColor changed from "false" to "true"
then
	logInfo("Rule","mode TV")
	// STOP Sonos salon
	SonosSalonStop.sendCommand(ON)
end

“false” and “true” in the trigger are quoted, but they aren’t keywords, so I assume that it is correct?

The generated YAML for the same rule is:

version: 1
rules:
  "122fc6f8bb":
    template: mode-tv-yaml
    templateState: INSTANTIATED
    label: Mode TV template based rule
    tags: []
    description: This rule template is parsed using the "old" YAML parser
    visibility: VISIBLE
    config:
      sourceItem: DemoColor
    configDescriptions:
      sourceItem:
        context: item
        description: The source Item whose state to monitor
        label: Source Item
        required: true
        type: TEXT
        readOnly: false
        multiple: false
        advanced: false
        verify: false
        limitToOptions: true
    actions:
      - id: script
        config:
          type: DSL
          script: |
            logInfo("Rule","mode TV")
            // STOP Sonos salon
            SonosSalonStop.sendCommand(ON)
        type: script.ScriptAction
        inputs: {}
    triggers:
      - id: "1"
        config:
          itemName: DemoColor
          previousState: "false"
          state: "true"
        type: core.ItemStateChangeTrigger

Please note, this rule has been created from a template, which is why it contains a configuration description. The “old YAML” format don’t show those.

I should probably do some more work on the generated YAML with regard to hiding empty stuff. templateState should also be hidden, it’s a RuleEngine managed state. visibility should probably also be hidden if VISIBLE, as that is “standard” for almost every rule.

I’ll probably have to make a choice when exporting template-based rules to YAML, where you can either export the “template stub” (without triggers, conditions and actions, only containing “the basics”, the configuration and the template reference) or as a “standalone” where I strip the configuration and config description stuff. But, when viewed in the “Code” tab, it should probably be closer to this?

Since DSL doesn’t support templating, exporting to DSL will automatically mean to export it as “standalone”.

From StateAndCommandProvider the methods getAllCommand() and getAllStates() are not used anywhere in openHAB, so I proposed deteting these. It would be good if here Command and State are not handled separately.

The formatting/serialization examples of DSL Rule sometimes look rendered with 4 and 8 spaces, and sometimes with 8 and 12 spaces, depending on where the examples are loaded. Sso maybe there are some tabulators involved sometimes, and sometimes four spaces.

You do not need an instance, all the methods are static. Have you tried with:

import org.openhab.core.model.script.scoping.StateAndCommandProvider;

For some reason, even though the fields are static, the methods aren’t:

I can’t treat State and Command as the same, because they use different classes. There’s no point in deleting these methods just because they weren’t in use, how many bytes do that save in the JAR? I doubt that’s very many.

It’s much better to get them separately, than to have to apply “incorrect” constants (not all constants are).

It seems like the formatter uses tabs yes, I haven’t done anything about that. Tabs are much better than spaces anyway as I see it, and if that’s the default, why should I change it?

It’s just GitHub that can’t handle tabs properly, why I have no idea, but everything else I use for code have no problem respecting the tab width I set.

Looks more and more promising.

Are imports and global variables already regenerated by the DSL generation ?

What about other rule engines, like JSScripting for example? Do you think it will be possible to generate their file format? And so to convert from UI rule to file rule.

No, because YAML and managed rules can’t have shared context. I’ve made this method in DslRuleProvider to detect shared context, because rules with shared context cannot be exported to either DSL or YAML:

    private boolean hasSharedContext(INode ruleNode) {
        INode node = ruleNode;
        EObject eObject;
        while ((node = node.getPreviousSibling()) != null) {
            if (node instanceof ILeafNode leaf && leaf.isHidden()) {
                continue;
            }
            if ((eObject = node.getSemanticElement()) != null && !(eObject instanceof RuleImpl)) {
                return true;
            }
        }

        node = ruleNode;
        while ((node = node.getNextSibling()) != null) {
            if (node instanceof ILeafNode leaf && leaf.isHidden()) {
                continue;
            }
            if ((eObject = node.getSemanticElement()) != null && !(eObject instanceof RuleImpl)) {
                return true;
            }
        }

        return false;
    }

The reason is that the rule relies on something outside the rule, DSL’s “shared context” is very similar in concept to other scripting languages’ SimpleRules in that the Rule object alone isn’t enough to make the rule work, it references “hidden information” that exists outside the rule. Because of this, these rules can only exist as file-based rules, since the “hidden information” is created when the file is parsed and the Rule is created. In both cases, a reference is embedded in the Rule object that lets the RuleEngine look up and “use” the “hidden information” when the rule is triggered.

I don’t think that is possible simply because apart for DSL, YAML or JSON, there are no defined file-format for rules. Other rule engines basically build the rule programmatically, there’s no “predefined way” to make the file content. If some of them had a defined syntax, they could themselves implement a RuleSerializer service, and make generation possible that way. But, there would be a challenge in the REST API, because the supported MIME types is hardcoded in the endpoint. Except for the hard-coding of the MIME type, adding support by adding RuleSerializer services should already work in theory.

DemoColor must be a String Item so that should be correct. false and true are neither enumerated states nor commands for OH.

If I were going to instantiate a rule from a template in YAML I’d only manually write the stub, right? So I would export it the same way. I would expect the export to be as close to what I would write manually as reasonable.

However, we should be able to see the actual code so it seems reasonable to show everything (configuration and the code with the properties applied) in the code tab. As someone who needs to support users of my templates, having the ability to see the actual code somewhere is very important. It immediately tells me if they are running with the latest and if they have modified the code after instantiating the rule.

The YAML file would be the most direct way to do this for the other languages I think which is supported. So we are not completely leaving out the other languages.

1 Like

A YAML rule can contain Conditions and Actions using any language (the language add-on must be installed for the rule to work, but this is not a requirement for the rule itself to exist). If you create a rule in the UI and use “Scripted Action” with Ruby or JavaScript for example, you can export that to YAML, and it will work just fine. But neither of these languages define their own “rule format”, which is why you can’t “export to them”.

The rules I have in my dev system has been used to test so many different things, that they don’t make any sense as rules, but they still show the syntax. Here is a rule, based on a template, that uses JavaScript:

version: 1
rules:
  dd46e76c57:
    template: ysc:simulate_sunrise
    templateState: INSTANTIATED
    label: Simulate Sunrise Managed
    tags:
      - marketplace:127216
    description: This rule will gradually increase a Dimmer or Color item to the target brightness and time over a configurable period.
    visibility: VISIBLE
    config:
      brightnessItem: MagicDimmer
      targetBrightness: 100
      fixedTargetTime: 09:00
      itemTargetTime: ""
      sunriseDuration: 60
      colorPrefix: ""
    configDescriptions:
      itemTargetTime:
        context: item
        description: "DateTime Item that holds the target time (for instance, linked to the Sunrise End Time channel of an Astro Sun Thing). Set either this or a fixed target time below."
        label: Target Time (DateTime Item)
        required: false
        type: TEXT
        readOnly: false
        multiple: false
        advanced: false
        verify: false
        limitToOptions: true
        default: ""
      fixedTargetTime:
        context: time
        description: Set a fixed target time - ignored if Target Time (DateTime Item) is set above.
        label: Fixed Target Time
        required: false
        type: TEXT
        readOnly: false
        multiple: false
        advanced: false
        verify: false
        limitToOptions: true
      targetBrightness:
        description: Brightness to reach at the target time.
        label: Target Brightness
        required: true
        type: INTEGER
        readOnly: false
        multiple: false
        advanced: false
        verify: false
        limitToOptions: true
        default: "100"
      sunriseDuration:
        description: Duration of the sunrise in minutes (The brightness will be set to 0 at the start of the period and gradually every minute to the target brightness until the end).
        label: Sunrise Duration
        required: true
        type: INTEGER
        readOnly: false
        multiple: false
        advanced: false
        verify: false
        limitToOptions: true
        default: "60"
      brightnessItem:
        context: item
        description: Dimmer or Color Item to use to control the brightness.
        label: Brightness Item
        required: true
        type: TEXT
        readOnly: false
        multiple: false
        advanced: false
        verify: false
        limitToOptions: true
      colorPrefix:
        description: "In case of a Color Item set above, prefix the command with the comma-separated Hue,Saturation components to send to the item (a separator comma and the brightness will be appended)."
        label: Color Prefix
        required: false
        type: TEXT
        readOnly: false
        multiple: false
        advanced: false
        verify: false
        limitToOptions: true
        default: ""
    actions:
      - id: "2"
        label: Calculate & set the target brightness
        description: Sets the brightness appropriately or do nothing if outside the sunrise time
        config:
          type: JavaScript
          script: |
            // set by the rule template
            var itemTargetTime = "";
            var fixedTargetTime = "09:00";
            var sunriseDuration = 60;
            var targetBrightness = 100;
            var brightnessItem = "MagicDimmer";
            var colorPrefix = "";

            var openhab = (typeof(require) === "function") ? require("@runtime") : {
              ir: ir, events: events
            };

            var logger = Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.rule." + this.ctx.ruleUID);

            // returns the number of minutes past midnight for a Date object
            function getMinutesPastMidnight(date) {
              return date.getHours() * 60 + date.getMinutes();
            }


            // returns the brightness to set at the current time (Date), given the target time (Date),
            // target brightness (int) & desired sunrise duration (int)

            function getBrightnessAtTime(currentTime, targetTime, targetBrightness, sunriseDuration) {
              var currentMinutes = getMinutesPastMidnight(now);
              var targetMinutes = getMinutesPastMidnight(targetTime);
              if (currentMinutes > targetMinutes) return null;
              if (currentMinutes < targetMinutes - sunriseDuration) return null;
              var minutesToGo = targetMinutes - currentMinutes;
              return parseInt(parseInt(targetBrightness) * ((sunriseDuration - minutesToGo) / sunriseDuration));
            }

            var now = new Date();
            var targetTime = null;

            if (itemTargetTime) {
              targetTime = new Date(openhab.ir.getItem(itemTargetTime).getState());
            } else if (fixedTargetTime.match(/\d\d:\d\d/)) {
              targetTime = new Date();
              targetTime.setHours(parseInt(fixedTargetTime.split(":")[0]));
              targetTime.setMinutes(parseInt(fixedTargetTime.split(":")[1]));
              targetTime.setSeconds(0);
            } else {
              logger.warn("Invalid target time");
            }

            if (targetTime != null) {
              var brightness = getBrightnessAtTime(now, targetTime, targetBrightness, sunriseDuration);
              if (brightness != null) {
                openhab.events.sendCommand(brightnessItem, (colorPrefix ? colorPrefix + "," : "") + brightness.toString());
              }
            }
        type: script.ScriptAction
        inputs: {}
      - id: "3"
        config:
          offset: 120
          config: milllan:panel-heater:e7c586766b
        type: milllanpanel.setTimeZoneOffset#7ca16fdb07368b3c8b6342a44d2011b8
        inputs: {}
      - id: "4"
        config:
          itemName: Spycast_Volum
          state: "100"
        type: core.ItemStateUpdateAction
        inputs: {}
      - id: "5"
        config:
          date: 2025-10-08T09:40:08
          config: astro:sun:sun
        type: astro.getAzimuth#3a305de48b10765aac73dccc4ce13842
        inputs: {}
    triggers:
      - id: "1"
        config:
          cronExpression: 0 * * * * ? *
        type: timer.GenericCronTrigger

I’m not a 100% sure what you’re asking, but the way I imagined it, is that if you exported the following rule:

version: 1
rules:
  "122fc6f8bb":
    template: mode-tv-yaml
    templateState: INSTANTIATED
    label: Mode TV template based rule
    tags: []
    description: This rule template is parsed using the "old" YAML parser
    visibility: VISIBLE
    config:
      sourceItem: DemoColor
    configDescriptions:
      sourceItem:
        context: item
        description: The source Item whose state to monitor
        label: Source Item
        required: true
        type: TEXT
        readOnly: false
        multiple: false
        advanced: false
        verify: false
        limitToOptions: true
    actions:
      - id: script
        config:
          type: DSL
          script: |
            logInfo("Rule","mode TV")
            // STOP Sonos salon
            SonosSalonStop.sendCommand(ON)
        type: script.ScriptAction
        inputs: {}
    triggers:
      - id: "1"
        config:
          itemName: DemoColor
          previousState: "false"
          state: "true"
        type: core.ItemStateChangeTrigger

..as a “stub”, you’d get:

version: 1
rules:
  "122fc6f8bb":
    template: mode-tv-yaml
    label: Mode TV template based rule
    description: This rule template is parsed using the "old" YAML parser
    config:
      sourceItem: DemoColor

..and as a “detemplated rule”, you’d get:

version: 1
rules:
  "122fc6f8bb":
    label: Mode TV template based rule
    description: This rule template is parsed using the "old" YAML parser
    actions:
      - id: script
        config:
          type: DSL
          script: |
            logInfo("Rule","mode TV")
            // STOP Sonos salon
            SonosSalonStop.sendCommand(ON)
        type: script.ScriptAction
        inputs: {}
    triggers:
      - id: "1"
        config:
          itemName: DemoColor
          previousState: "false"
          state: "true"
        type: core.ItemStateChangeTrigger

It was a rhetorical question more than anyrthing.

When I instantiate a rule from a template in a YAML file, I’ll only fill out the properties and the template ID.

When I want to export the instantiated rule to become a YAML file, I’d want it to look like I wrote it. I would not want the actual actions and triggers and conditions as part of what was exported. I’d want it to appear as if I wrote it.

Yes, but this will also apply to managed rules that might be instantiated from a template. There already is the possibility to choose to “detemplate” it if you duplicate the rule - are you perhaps suggesting that if that’s the YAML they want, they should first duplicate it to a non-templated rule and then export it?

No.

If it’s not attached to a template, I’d expect the export to be a rule just like any other. If it’s been detemplated, it’s no longer attached. So that isn’t part of this discussion. It’s a rule like any other and should be exported as such.

If it is still attached to a template, the only parts of the instantiated rule are the template reference and the configuration. All the rest comes from the template. So in this case I’d expect the YAML to be

It doesn’t make sense to export the triggers and actions and all the rest because either:

  1. the rule cannot be regenerated from the template because these will override the changes
  2. these will be wiped out when the rule is regenerated.

A rule created from a template should still be able to be regenerated from the template even if it’s been exported to a YAML file. And it should be clear that’s the case. It would be awful for a user to see code in their rule that doesn’t match what’s actually running or to break the regenerate feature just because a managed rule was saved to a YAML file.